Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add @winteries/dsh-turn-fold
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
README
简体中文(默认) | English
DeepSeek Harness (DSH) native folding + poker-card visuals + per-turn performance metrics.
Scope of this English README: it is a condensed companion to the Chinese README.md (the primary document), not a line-by-line mirror. Everything both documents describe is kept in sync by
tests/unit.docs.test.mjs(shared facts and paired statements fail the suite when only one side is changed). The Chinese side additionally carries the Compatibility Architecture chapter — three-state semantics (UNKNOWN ≠ LEGACY), PROBE ≠ COMMIT, the per-release capability matrix, the historical 0.1.1–0.1.6 live-host acceptance records and the two-line diagnostics format.
DSH natively owns all folding semantics (grouping, membership, disclosure state, paging, search reveal, history state); this plugin is a pure UI enhancement on top of the official fold engine:
- Enhanced Turn Process Bar: replaces the official
turn-processrenderer with a richer turn bar — poker leading icon, duration, TTFT, tokens, tok/s, cache-hit rate, right-aligned "Turn N", and status words for abnormal endings (Stopped / Failed / Interrupted). Fold state comes entirely from the official owner state (turnProcess.open / setOpen / foldable / hasContent); clicking the bar only callsturnProcess.setOpen(), and member visibility is handled by the official seat. - Running Turn Bar: appears the moment the turn starts (the
turn/startprojection) — the official renderer renders nothing at that stage, and this plugin fills the gap: duration starts from 0s and ticks in real time, tokens / tok/s / cache-hit update as real data arrives. It is a status surface only: it maintains no fold state and hides no members; when the turn closes it hands over smoothly to the full collapsed turn bar. - Poker Step Skin: the official step group bar (
ChatGroupSeat/ProcessGroupHeader) works as-is; the plugin adds a pure-CSS poker reskin via the official DOM hooks (data-step-process-icon/data-process-activity), mapping official activity semantics to suits (♥ think/questions · ♠ read/search · ♦ edit/write · ♣ commands/code · 🐋whale orchestration/plan/subagents). Soft dependency: if the hooks go away, the skin disappears and the official icons and folding stay intact. - Real-metrics promise: every statistic is computed from official real data only (
TurnLocation.start/end, per-stepusageandfinalNode.timing, the official aggregatedtokenUsageon turn-tail). The running token field additionally shows a presentation-only visual counter that fills the gaps between the official discrete usage reports: it takes part in nothing (not tok/s, not cache hit, not TTFT, not the durable projection) and is never persisted; real usage arriving calibrates it immediately (the UI jumps to the new real value and then keeps growing slowly), and once the turn closes the final value always equals the official real token count (reset to zero on settle). - Plugin settings: turn-bar field visibility (duration/TTFT/tokens/tok/s/cache-hit) plus one unified fold-icon mode
iconStyle(animated poker / official icons — a single setting drives both the Turn leading icon and the Step skin), persisted inlocalStorage['dsh-turn-fold:settings']. Fully decoupled from the official transcriptView — the official Compact / Standard / Detailed / Verbose modes keep working; the plugin only enhances their UI.
Does not modify any @deepseek-ai/dsh-* source code.
Ownership boundary
| Owner | Scope |
|---|---|
| DSH owns | grouping · membership · disclosure (when/whether to fold) · paging · search reveal · history state · step group bar and its titles (official semantics like "Reading…", "Read 3 files") |
| dsh-turn-fold owns | enhanced turn bar · running turn status · poker visualization · metrics presentation · animations · plugin settings |
Turn bar forms
Running (turn/start → turn/end):
[flip animation] 0s · TTFT — · — tokens · — tok/s · Cache — ← every slot present from 0s
────────────────────────────────────────
[official process content streams in (official liveProcess semantics, always expanded)]
[flip animation] 13s · TTFT 0.8s · 3,214 tokens · 247 tok/s · Cache —
──────────────────────────────────────── ← arriving real data only replaces the —
Turn closed (collapsed by default, click to expand):
[stack/fan] 22m 34s · TTFT 4.9s · 370,202 tokens · 2.4 tok/s · Cache 93.99% Turn 13
────────────────────────────────────────
[final summary body (official rendering)]
Failed / stopped:
[stack] Failed · 48s · TTFT — · 7,812 tokens · 28 tok/s · Cache — Turn 13
[stack] Stopped · 21s · TTFT — · 3,201 tokens · Cache — Turn 13
- Field slots always exist: the fields enabled in settings occupy their slots from 0s
(with no real data they show
—); arriving real data only replaces the—, and no new field segment appears at completion; a disabled field disappears entirely (not even—). Every statistic comes from official real data and is never fabricated (no estimation, no data =—). The running token digits are a presentation-only display value: with official data = the real value plus a bounded visual offset; before the first official usage report = an independent visual counter (see "real data layer / display layer" below). The screen-reader (sr-only) text is always the official real value (— tokenuntil the real value arrives). - Metric sources (all official real data):
- Duration:
TurnLocation.start.time → end.time(live clock fills in while running); - TTFT: durable first — the plugin's own official projection
turnFoldMetrics(see "durable Turn metrics" below) gives the turn's stable first-token latency (firstTokenTime - stepStartTime, the same semantics as official statistics); then the client-sidefinalNode.timing(present only when this page saw it live); while running, before official timing is ready, a provisional visible-first-token latency (data.time - stepStartTime; remembered once observed in the page, and kept on settle when the exact value is missing instead of falling back to—); - Tokens / cache-hit: the official aggregated
tokenUsageon turn-tail after the turn closes (deriveTurnTokenUsagefolds every billed attempt, including retried ones; cache denominator = prompt-side total); while running or when absent, per-step usage accumulation is used (the official step data store is independent of node visibility, so hidden tool-only steps are still counted); - tok/s: decode-speed (the same definition as the official StatsPills) —
Σ outputTokens ÷ Σ(completedTime − firstTokenTime), counting only steps where first-token time, completion time and outputTokens are all present; TTFT, tool execution, waiting between steps and the whole turn's wall-clock time never enter the denominator. The decode evidence is durable first as well (projection), with the client-side step aggregate as the fallback.
- Duration:
- Durable Turn metrics (the
turnFoldMetricsprojection, and why it is needed): the official client recordsfinalNode.timing.firstTokenTimeonly when it receivesassistant/live-chunk(a transient client event that is not persisted) (ui-chat/.../assistant.ts:146,154), and neithersettleMessage()nor the history-rebuildfallbackState()restores it from the persistedassistant/message.stream— so after a page reload or when opening a historical session TTFT and tok/s both disappear (TTFT —/— tok/s), while the official bottom sessionStats still has both values (it folds them durably withassistantStreamFirstTokenTime(event.data.stream)). The plugin therefore registers its own projection on the host half (official public extension pointctx.sessionProjections.register; the unit is plugin-owned, read-only, returns the same reference and produces no extra broadcasts), folding per-turn metrics with event semantics identical, one by one, to the official sessionStats; the host pushes it to the client over the projection wire and the front end reads it withuseProjection('turnFoldMetrics')— LIVE / SETTLED / RELOAD / HISTORY all read from the same source. On older hosts without that service the whole registration is skipped and the front end falls back to client-side step data (degradation = those two fields show—after a reload; nothing is fabricated). Three numbers per turn, capped at 2000 turns (older turns degrade to—). - Real data layer / display layer (the running token):
canonicalTokens(real data layer) =computeTurnMetrics'stokens(official usage / turn-tail aggregate) — the only authoritative value; it takes part in tok/s, cache hit, TTFT and the durable projection. Before official usage arrives it staysundefinedand is never written back from a display value;displayTokens(display layer) is used by the running UI only, lives entirely in a component-local ref (never written to localStorage / the projection / any node data) and takes part in no statistic:- with canonical:
displayTokens = canonicalTokens + visual offset; - without canonical (official usage has not reported yet): once the running
assistant-step already has visible reasoning/text (official
blockIsVisiblesemantics: tool-call / empty text do not count) the token slot starts a presentation-only visual counter — its first frame is1(it does not wait for 200ms), it then grows on the same cadence with the deterministic step sequence+1, +1, +10(no random jitter), independently capped at 500, and it calibrates to the official token count the moment the first real usage arrives; the screen-reader text still shows— tokenuntil then; - hint: it does not start right after sending a message, while the model has not produced any visible content yet (it never starts jumping up from 0 on its own);
- with canonical:
- it only grows while the model is really streaming visible assistant output: during
tool execution, waiting for results/approval/subagents, or when that step has already
settled (or has no visible block yet) the digits freeze and do not fall back to
— token(avoiding a137 → — → numberjump); - the cadence is fixed and deterministic: one UI tick every 200ms;
- the canonical visual-offset ceiling is
min(500, max(20, canonicalTokens × 5%))(e.g. 1,000 → +50; 10,000 → +500; 100,000 → +500), stops at the ceiling and waits for the next real usage; - official usage update → immediate calibration: bootstrap/offset are all cleared and
the next frame equals the new real value directly (never rolls slowly over from the old
display value), then keeps growing slowly from the new baseline. Calibration does not
change the subscription state: if that assistant-step is still streaming visible
content the ticker stays subscribed and the next tick continues from the new baseline;
if it has meanwhile entered tool execution / waiting (no running visible step) only the
real value is shown and the ticker unsubscribes. Turn settle → everything resets,
and a historical session / page reload shows the official real value only (
— tokenagain without canonical); - performance: the visual ticker subscribes only while there is really an animation (running + visible output + the Token field enabled + reduce-motion off) — historical turns, closed turns and tool/waiting phases never subscribe; with no active subscriber the module-level interval stops entirely (100 historical turns = 0 subscribers + 0 timers);
- with the system "reduce motion" setting on it snaps back immediately (dropping the
visual offset already rolled out: canonical → show canonical, no canonical → back to
— token) and unsubscribes; toggling that setting mid-session takes effect live (media-query change event drives the re-render). In normal mode the visual digits roll while the sr-only text stays canonical (screen readers always get the real value).
- Rolling-digit animation: while running, each digit rolls to its new value (odometer effect with back easing); a full sr-only copy keeps screen readers intact; degrades to static digits under "reduce motion".
- Permanent divider line below the bar (
--dsw-alias-line-secondarytoken chain, theme-aware). - Accessibility:
aria-expanded/aria-label, keyboard operable (Enter / Space); non-collapsible turns (e.g. official Verbose semantics, aborted/failed turns) render as a static bar. - All four official transcript modes work: the turn bar renders under Compact /
Standard / Detailed / Verbose;
foldable=false(e.g. Verbose) and paginated history (withoutturn/startin the window, where the official control is absent) degrade gracefully.
Poker Step Skin
The official step group bar (official title semantics, official shimmer, official paging and search reveal) works untouched; the plugin only reskins it:
- A transparent-bodied poker card (CSS mask, current-color stroke + suit pip,
wallpaper shows through) replaces the official activity icon. The card shares one
design language with the Turn-bar poker icon: a 24×24 artwork canvas, a 16-unit mask
rendered at 24px (1.5px/unit) → card outer edge ≈9.62×13.05px with a 1.05px stroke; and
both sides put that 24px canvas inside the official 16×16 leading box, centred and
overflowing (the Turn bar's
.ccg-poker-iconis 16×16, the same size as the official.leading) ⇒ ink indent 1.3px and a 6(gap)+1.3 = 7.3px gap to the text, pixel-identical on both bars at the default content font size. Recorded boundary: the official.leadingiscalc(16px + var(--dsh-content-font-delta, 0px)), so it grows with a larger content font, while the plugin's.ccg-poker-iconand bar label are fixed (16px / 14px) and do not scale — at other font sizes the two bars are no longer pixel-equal (the earlier 24px box left 5.3px of empty space on each side, making the Turn bar's letter spacing 4px wider and shifting its card 4px right — verified by measurement); - Completed two-state icon (fold-state aware): collapsed = a stack of suit cards
(closed = cards put away), expanded = a fan (open = cards looked through) — same face
pool and ordering as the running faces, with the geometry taken straight from the Turn
bar's stack3/stack5 · fan3/fan5 transform tables (
icons/default.jsondata source). Card identity is continuous: the same card id is the same card in stack and fan with an unchanged layer order — stack3's top card card3 is the rightmost card in fan3 (same shape as fan5: the middle card does not rotate, the sides are symmetric, a larger id sits further right), and on expand it travels from the top-left position to the right while staying on top. The card count follows this Process Group's tool-call count: the officialsummary.counts[].countsum ≤3 → 3 cards, ≥4 → 5 cards (old hosts / no data → the safe 5-card fallback, never guessing 3). Open/close has a morph transition: pre-sampled stack↔fan interpolation frames (cubic-bezier(.22,1,.36,1) sampling, 400ms, 16 samples = 17 keyframes, each frame a static SVG) swapped frame-by-frame via CSS keyframes — the animation replays from the start on every state change (same-URL mask images share one SMIL timeline across the page in Chromium and do not restart on re-apply; the frame sequence sidesteps that), with the per-frame occluder synced too. The easing applies to the "geometry progress" only and the two directions mirror each other: the frame-time progress u=0→1 first goes through that one easing to give the geometry progress (expandprogress = E(u), closeprogress = 1 - E(u)) while the geometry helper does plain linear interpolation — so both directions start fastest, decelerate and settle softly (measured: ≈74% of the distance in the first 100ms, ≈0.25% in the last 100ms), the same feel as the Turn bar'stransition: transform .45s cubic-bezier(.22,1,.36,1). It is not a time reversal (E(1-u)): that would make the close start at zero speed and end steepest (only ≈0.25% in the first 100ms, ≈74% in the last 100ms) — "slow then abrupt", the opposite of expanding. Overlaps use real knockout masking: each lower card carries an inline luminance mask whose occluder is a solid black card face (fill=black) covering the whole upper-card footprint — the card body stays transparent (wallpaper/image backgrounds show through) while lower strokes and pips never bleed through the upper card (occluders must be inlined shapes — Chromium does not render<use>references inside<mask>content). The official chevron stays hidden under the Poker skin (normal/hover/focus/active all show Poker; official hover tint and focus ring are untouched); - The suit maps from the official
data-process-activityvalue (singleACTIVITY_SUITsource generating the CSS): thinking/questions → ♥, read/readImage/search/webSearch/ webFetch → ♠, edit/write → ♦, commands/code → ♣, subagents/plan/tools → 🐋whale (DeepSeek logo); unregistered activities fall back to ♥ (this mapping now mainly serves as the reduced-motion running fallback); - Running face rotation · flat-rotated 35.5° (not the Turn bar's vertical-diagonal-axis
flip — the two are deliberately different): a running step (official title shimmer. Soft
running-state dependencies — DSH 0.1.7 renders
data-text-shimmer, DSH 0.2.0+ rendersdata-shimmer; both are real official historical contracts and the plugin supports both) keeps the five equal card faces (♠ ♥ ♦ ♣ + the DeepSeek whale; no front/back) and rotates the whole visible animation (cards, suits, and the masks they reference) around the icon centre by 35.5°. A self-running SVG (SMIL) is used as the card's CSS mask, so the stroke color keeps followingcurrentColor:- Design source: the reference
docs/扑克牌轮换_动态蒙版遮挡_文件图标加强版.html— the animation body comes from row 3 (── 第三行:五牌面轮换动画 ──): the five-face rotation itself (const ANIM_SVG) plussvg3dRotated(): insert<g class="anim-root-rotation" transform="rotate(angle 8 8)">right after</defs>. The reference's own comment: "rotate the whole visible animation around the 16×16 canvas centre without touching any keyframe;defskeeps its original coordinates while the outer transform rotates cards, suits and the referenced masks together". The 35.5° target angle and the design label themselves come from row 5 — the click-toggle variant under── 第五行:两个"点击切换型"动画 ──("牌面轮换 · 平面旋转 35.5°" /const target = toward35 ? 35.5 : 0; row 3's own previews are rendered atANIM_ANGLES = [0, 30, 45, 60, 90]and carry no 35.5 literal); - Angle:
icons/default.json → pokerSpin.restAngle(≈35.5377° =atan(w/h), the same data source the Turn bar's axis reads). The generator asserts it rounds to one decimal equal to the row-5 variant's literal 35.5 — drift on either side fails the run; - Generated by
npm run sync:step-animinto the>>> step-running-poker-svgmarker block ofclient.js. What the script asserts is the wiring and structure: row 3's animation body and flat-rotation wiring are still there (const ANIM_SVG,svg3dRotated,anim-root-rotation,rotate(' + angle + ' 8 8)), row 5's 35.5 literal is still there, the rotation group genuinely wraps every phase, and the output carries none of the banned identifiers from the row-4/5 axis-rotation era; it also prints the output's counts to stdout. The count invariants themselves — 5 phase groups / 72 animateTransform / 15 animate /0.8s×82 /4s×5 — are asserted bytests/unit.css.test.mjs's "flat rotation contract" (i.e. "rotation added, keyframes untouched"); the script is not a pre-commit guard for those numbers; - Structure: the five-face rotation (every 0.8s two full flat cards drift slightly apart
on opposite diagonals and merge again, the only rotate being a ±3.1° 2D in-plane tilt; the
layer order flips mid-way with
discrete; four dynamic luminance masks knock the lower card's strokes out under the upper card; five phases form a 4s loop: diamond → club → spade → heart → deepseek → back to diamond) wrapped in<g class="tf-step-flat-rotation" transform="rotate(35.5377 8 8)">; - Recorded boundary: this SVG is baked into
client.jsat build time; a runtime localStorage icon-pack override does not rewrite it (overrides only reach the Turn-bar flip and the runtime-generated completed two-state faces) — same nature as before; - The running look is independent of the card count, and when the step settles the shimmer disappears → automatic fallback to the completed two-state icon (collapsed stack / expanded fan) — pure CSS cascade, zero JS running state; static suit card under reduced motion;
- The rotation angle lives only in the SVG
transformattribute: CSS must never restate it (a CSS transform silently overrides the attribute — the Turn bar hit exactly that bug). The step-side class nametf-step-flat-rotationis deliberately distinct from the Turn bar'sccg-axis-rest-rotation; guards live intests/unit.css.test.mjs("flat rotation contract");
- Design source: the reference
- Step file list (the "which files did this step touch" text at the group header's tail, pure CSS
text): data comes from the official API only —
GroupSnapshot.members(each member node's key) → the officialChatNodeStore.get(key)tool node (ToolChatData.root=name+argsRaw) →JSON.parse(argsRaw)forfile_path/filePath/file/target/path; the outlet is the official header button'sbutton[data-process-activity]::after{content:"…"}— no official DOM writes, no added official attributes, no DOM observation (architecture red lines: see the banned identifiers intests/unit.compat.test.mjsand the bridge guards intests/unit.step-cards.test.mjs). Display rule: basenames, de-duplicated, then cut by a whole-name budget — at most 3 names, with the budget derived from the content width (stepFilesBudgetPx()=clamp(content width − 300px, 150px, 380px), where the 300px reserves room for the label, icon and gear). The content width is measured: it takes theclientWidthof a row inside the official content column ([data-chat-group-key]group roots, session-scoped when a session anchor exists, rows with width 0 skipped) — the official--dsh-chat-content-widthis declared on the conversation content element (not:root, and custom properties do not inherit upward) and its computed value isvar()/clamp()text thatparseFloatcannot read a number from, so it is not a usable numeric source. Only when nothing at all can be measured does it fall back to the viewport estimate (window.innerWidth − 312, floor 320, finally 680), and it is re-measured on window resize and when the group entry list changes. Names are then compared in ASCII-equivalent characters (ASCII 1, CJK/full width 2.4 ≈ 1em/6px) ⇒ ≈63 characters on wide windows (filling 380px), while narrow windows clamp atSTEP_FILES_MIN_PX(150px ≈ 25 equivalent ASCII characters; the old fixed fallback constantSTEP_FILES_BUDGET(32 characters ≈ 192px) is deleted). Names that no longer fit are folded whole into+N, which must itself fit the budget. The first name is the one exception: to keep at least one name (otherwise the text carries no information) it is always shown, so it may itself exceed the budget — and if it is wider than themax-widthit gets ellipsized. That is a recorded boundary: do not expect "never half a filename" to hold for a single over-long name. Themax-widthwritten into CSS is the same pixel value the data layer used (the old fixed44chfallback used a different yardstick and wasted half the available space — measured: on a 680px row the old rule spent only 130–167px with 231–468px left empty); the normal case never triggers the CSS clamp, and the earlierChatGroup…fragment problem stays fixed. Directory-ish tools (glob/grep/find/ls) contribute no filename from theirpath(it is a search root;file_pathis always trusted); directories (trailing separator) andurlvalues are not files; the list shows for running and completed groups alike (while running it accumulates from official tool data, withturnDataSource(turn,'tool-call')as a refresh trigger only), and disappears with the group/session. It lives in its own stylesheet (style-step-files) and is not toggled byiconStyle(information is not skin, so it shows in native mode too); the legacy host surface (0.1.2–0.1.6) produces none; recorded boundary: only the top-level member nodes of the group are read — files that appear exclusively in PTC child calls (ToolCallBlock.subCalls) are not included; - Soft dependency: every selector is pinned to the official DOM hooks; the gate is
the skin
<style>element'sdisabledproperty (the plugin never writes globaldocument.bodystate) — if DSH renames the hooks, the worst degradation is the skin disappearing and the official icon showing as-is; official folding is unaffected. The running-state recognition relies on the official shimmer attributes (either one present → Running Step Poker animation; both absent → static Poker fallback; even if the visual hooks fail, Step Fold / Tool / Think / Turn Fold and page stability are never affected).
Install
Option 1 (recommended): install from npm
Published on npm: @winteries/dsh-turn-fold
# Official command (recommended)
dsh plugin --profile web add @winteries/dsh-turn-fold
# Or install from GitHub source
dsh plugin --profile web add github:Winter-And-You-Gone/dsh-turn-fold
dsh plugin adds the package to the profile's pnpm dependencies and appends it to the bundle layer (dsh.profile.bundles) automatically — no manual file edits. Verify with:
dsh --profile web --dump-config # confirm the "@winteries/dsh-turn-fold" layer appears
Then fully quit the DSH process and restart.
Option 2: manual install.ps1
# Put the plugin directory into your existing plugin directory, then:
.\install.ps1 -PluginSource "<your plugin directory>"
# e.g. .\install.ps1 -PluginSource "C:\dsh-plugins\dsh-turn-fold"
# Without arguments the script uses its own directory as the plugin source
The script:
- Creates a Junction at
~/.dsh/profiles/node_modules/@winteries/dsh-turn-foldpointing to the plugin directory; - Appends a
- insert:registration to~/.dsh/profiles/web/cordis.patch.yml; - Verifies
require.resolvesucceeds.
Then fully quit the DSH process and restart.
Uninstall
# Official way: removes both the dependency and the plugin layer
dsh plugin --profile web remove @winteries/dsh-turn-fold
Manual (if installed via install.ps1):
Remove-Item "$env:DSH_HOME\profiles\node_modules\@winteries\dsh-turn-fold" -Force # remove the Junction
# and delete the matching insert block in cordis.patch.yml
Tests
npm install # first run: installs jsdom / react / react-dom (devDependencies)
npm test # node --test over tests/
npm run check # syntax check client.js / index.js
The suite loads the real client.js (via __ModuleLoader__ injection + __test
exports — no copy-paste drift). The table below is a curated view by capability, not an
exhaustive inventory — see the tests/ directory for the full list:
| File | Coverage |
|---|---|
unit.logic.test.mjs |
Metric pure functions: turnClockOf / computeTurnMetrics / readStepUsage (official TurnLocation / step usage / turn-tail aggregate), formatting, field visibility + localStorage persistence; identical inputs produce byte-identical outputs (no fake growth) |
unit.turn-renderer.test.mjs |
Turn renderer: open=false → click → setOpen(true), open=true → click → setOpen(false); foldable=false/aborted/error → static bar with no setOpen path; Running Bar appears at 0s, non-interactive, touches no fold state; smooth handover on turn close; degraded rendering without turnProcess |
unit.poker.test.mjs |
Activity→suit mapping (full official ProcessActivity vocabulary), stack/fan/flip SVG generation, running flip axis = vertical diagonal axis (axis angle = atan(w/h) + geometry/origin checks + a CSS-override regression guard), component rendering, reduced-motion and no-WAAPI static fallback |
unit.css.test.mjs |
Step skin gate (body[data-tf-step-skin]) + official DOM hook rules; flat-rotation contract (the tf-step-flat-rotation group wrapping every phase, angle taken verbatim from pokerSpin.restAngle and asserted to round to the design literal 35.5, five phases / 72 animateTransform / 15 animate / 0.8s×82 / 4s×5 untouched, ±3.1° planar tilt, four knockout masks, poker 5:7 card); architecture guard: the old engine's :has() hiding rules must be gone |
unit.gear.test.mjs |
Settings popup: field checkboxes, persistence, icon style / step skin selectors (hooks-order guard); the "poker" preview row rotating one face per second (pure shift + the real shared 1s clock + unsubscribe on close) |
unit.compat.test.mjs |
Registration audit: only turn-process is shadowed, exports.inject=['slots'], priority-conflict yielding, soft degradation of registration errors; architecture guard: zero old-engine identifiers / official-renderer delegation plumbing / transcriptView writes; the plugin has zero scroll permission (0 .scrollTop writes, no scrollIntoView/scrollTo/overflow-anchor, no data-chat-following-tail read, no data-conversation-scroll sweep, no DOM observers at all); rendering compatibility across the four official transcript modes |
unit.step-cards.test.mjs |
Step card-count bridge: official counts summation → 3/5 cards, morph identity continuity, per-group mask geometry, whole bridge chain (groupSource subscription, same-domain rule revocation/restoration, leader unmount cleanup); step file list: path extraction (settled/running/preparing/truncated/directory-tool/escaped), basename de-dup + +N folding, session-scope branches, running-state output, refresh on argument change, empty fallback without ChatNodeStore |
unit.step-session-scope.test.mjs |
Session scoping: selector prefix and escaping, bare-session branch regression lock, same-groupKey two-tree isolation, tree-only multi-session safe degradation (session-specific rules withdrawn, restored without a refresh, completedFaceSets retained, running rotation unaffected) |
unit.docs.test.mjs |
README zh/en pairing guard: language-neutral facts (ranges / identifiers / commits / selectors / storage keys) identical on both sides; paired statements for the parallel sections (the six style injections, running-metric semantics, the known-issue section) present on both sides; the style-injection list length == the actual number of injections in client.js (change one side and forget the other → red) |
unit.host-compat.test.mjs |
Cross-version capability matrix (three-state semantics, UNKNOWN ≠ LEGACY), registration gating (modern host never activates legacy; legacy host records Turns only), full-host selector generation per branch + jsdom two-tree matching, runtime resolution, metrics/fold decoupling, two-line diagnostics |
regression.test.mjs |
Historical regressions: live-clock orphan timer, no fake token growth (unchanged data → unchanged digits), gear stopPropagation, degradation requirements (corrupt icon pack/settings fall back to defaults) |
--test-isolation=none(built intonpm test) is required on Windows sandboxes where spawning child processes is not allowed; plain Linux/macOS CI can use it too (Node ≥ 22.9).
Poker leading icon
The fold-bar icons are driven by one unified mode (⚙ gear on the bar → the "Fold icons" selector in the popup); the turn bar's leading icon defaults to animated poker cards, and the same setting also decides the Step skin:
Completed state: stack of cards (≤3 tools+subagents → 3 cards, more → 5), fan when expanded;
Face pool: ♠ ♥ ♦ ♣ + DeepSeek whale logo (choose one of five), remembered per turn;
Settings preview: the four static previews (3-card stack / 3-card fan / 5-card stack / 5-card fan) shift their faces one slot through the face pool (♠♥♦♣ + DeepSeek whale) every second, with the previews phase-offset so four different faces are on screen at any moment; the fifth item is the real running flip component. The rotation reuses the shared 1s live clock that drives the duration stopwatch: it subscribes only while the popup is mounted and unsubscribes when it closes (no second timer, no idle ticking);
Running state: diagonal-axis card flip (four suits cycling, logo on the back), native SVG animation that survives re-renders;
Official icons (native): Turn keeps everything the enhanced bar shows (duration / TTFT / tokens / tok/s / cache / "Turn N" / gear / durable metrics / official fold behaviour — still rendered by
EnhancedTurnProcessView, not switched back to the officialTurnProcessNodeView); only the leading icon becomes an official-style fold chevron, geometry value-for-value the officialIconChevronDownOutlineRegular(viewBox 16, stroke 1, size 14), pointing down when collapsed androtate(180deg)up when expanded (the official 100ms transition); running / non-collapsible turns have no official fold chevron anyway, so the leading slot stays empty. Step fully restores the official ProcessGroupHeader's own icons — activity icon, hover/focus chevron, open/closed direction, shimmer, title and disclosure all as-is (implemented by disabling the plugin's two skin stylesheets,disabled=true; not one byte of official DOM is touched). Switching needs no page refresh:iconStyleis subscribed exactly once, unconditionally, inEnhancedTurnProcessViewand passed down as a plain string prop to the bar and the leading-icon factoryturnPokerIcon(iconStyle, …)(subscribing insideTurnBarViewwould be a conditional Hook —canTogglechanges with the turn lifecycle — so a source guard locks this down), and Step flips the two stylesheets'disabledgate synchronously viaapplyIconStyle();Migration: in the old settings, either
foldIconorstepSkinbeingnative→ after upgradingiconStyle=native(returning users do not suddenly get poker back); both poker → poker; an existing new field wins. Migration happens in the read layer only; the next real save writes justiconStyleand deletes those two keys, keeping unknown fields (nothing is written to disk at start-up);Occlusion: luminance mask cuts the overlapped region of lower cards following the upper card's transform; card bodies are transparent (correct over wallpapers);
Data source:
icons/default.json(suit paths, card geometry, stack/fan transforms, and thepokerSpinsemantic knobsrestAngle/strokeW/frames/flipMs) — runnpm run sync:iconsto inject,npm run icons:checkto verify. Keyframes are not stored in the data source:scaleX = cos(2πk/frames)is a formula (frames: 72= 5°/step, byte-identical to the historical literal table, locked by a guard), and the source no longer carries values nothing reads — the oldscaleKeys/scaleKeyTimes(73 values, zero runtime readers) andpokerAnimSVG(a 54 KB snapshot, zero consumers) are gone, which alone shrankclient.jsby ~59 KB. The Step running rotation is a build-time artifact on top of it:npm run sync:step-animregenerates it from the reference design's animation body (row 3) plus row 5's 35.5° design label, and the script reads onlypokerSpin.restAngle(plus the reference HTML) — changingpokerPips/pokerSpinDeepseekdoes not require re-running it; ifrestAnglechanges and the script is not re-run, the running angle stays stale (npm test's generation-block guard fails first).What an icon pack (
localStorage['dsh-turn-fold:icons']) reaches (the asymmetry is by design):Covered Not covered Turn-bar running flip + completed stack/fan faces Step running five-face rotation (baked into client.jsat build time bynpm run sync:step-animfrom the design reference — an icon-pack change does not reach it)Settings-preview faces Official chevron style ( iconStyle = native, not part of the data source)Step group-header skin (card size/masks/suit mapping: on plugin activation buildStepSkinCssreads the liveiconConfig, so it applies after a page refresh)— Geometry/timing knobs ( restAngle/strokeW/frames/flipMs/pokerPips/stack-fan tables)— To reset:
localStorage.removeItem('dsh-turn-fold:icons').
Custom icons (Agent Skill)
This plugin ships an agent skill dsh-turn-fold-customize-icons (the host half
index.js registers it via ctx.skills; active automatically when DSH 0.1.2+ ships
@deepseek-ai/dsh-skill). Tell your AI assistant "customize the poker icon", and it
loads the full workflow:
- Data source:
icons/default.json(single source of truth) - Sync after edits:
npm run sync:icons→npm run icons:check; when the edit touchespokerSpin.restAngle, alsonpm run sync:step-anim(the Step running rotation is generated from that one value plus the reference HTML —pokerPips/pokerSpinDeepseekchanges do not need it) - Quick preview: write
localStorage['dsh-turn-fold:icons']to override without code changes - Pitfall guide: environment-specific SVG rendering quirks
The skill body lives in assets/dsh-turn-fold-customize-icons.md and ships with the npm package.
CI & release
GitHub Actions runs syntax checks, the full npm test suite and npm pack --dry-run on
every PR / push to main; pushing a v* tag publishes to npm (OIDC Trusted Publishing)
and creates a GitHub Release. The same source is also published under the legacy package
name dsh-turn-fold (existing installs update through that name), so both names must
have Trusted Publishing configured against this repository's release workflow.
npm version patch # or minor / major: bumps the version and tags v*
git push --follow-tags
How it works (why no source patches are needed)
- The DSH session UI is assembled from Cordis plugins + the slot system; every chat-flow
block is dispatched through the
conversation.chat.nodekeyed slot by node kind, and registering the same key replaces that renderer (lowest renders). - This plugin shadows the official
turn-processrenderer withpriority: -1— the only official renderer it replaces. It does not shadowtool-call/assistant-step/context/user, does not scan official slot entries for delegation, and does not copy official inject hooks (official components are always rendered by their own official entries). - Fold semantics come entirely from the official owner state: the official
ChatNodeSeatbuildsturnProcess = { spec, foldable, hasContent, open, setOpen }for every node and hides/shows members itself (data-turn-process-hidden). The plugin bar only callsturnProcess.setOpen(!open)on click;canCollapsemirrors the official renderer (foldable && hasContent, and never collapsible for open/aborted/ error turns — the officialturnProcessAlwaysOpensemantics). - Running status bar: the official
turn-processnode is projected atturn/start, but the official renderer returns null until the turn closes — the plugin renderer shows the Running Turn Bar in that phase (0s start, real metrics, no folding behavior). When the turn closes, the same renderer switches to the full bar. - Known issue: follow release caused by the running bar (root cause not pinned down, the plugin
patches nothing): that row is 0px while running officially and about 37px here (24px row +
1px divider + 4/8 margins), and it sits at the top of the turn — i.e. above the reading anchor.
Observed chain: a layout change at the turn boundary opens the official 500ms reader-sample
window (
use-chat-viewportjudges the scroll event asmovedByReader, anduse-chat-reading'sonResizestops following during it) → the next turn's growth lands inside that window → the window closes with anearBottom(25px) re-decision → follow is released, so the running step bar can come to rest inside the composer's (in-scrollport, sticky) covered band. Live-machine evidence: disabling the plugin makes the symptom disappear — so the plugin's own 37px row-height change (0 → ≈37px at the turn top) is a contributor; but the root cause is not yet pinned down (the interaction between the official follow policy and the plugin's layout changes has not been fully attributed). Per the user's rule the plugin adds no compensation or patch (it writes no official scroll position, reads no official follow state, observes no DOM), and a guard test pins "zero scroll writes" (0.scrollTopwrites, noscrollIntoView/scrollTo/overflow-anchor, nodata-chat-following-tail/data-conversation-scroll, no ResizeObserver / MutationObserver / IntersectionObserver — seetests/unit.compat.test.mjs) so that no workaround can creep back in. The leading open lead (not a fix): the plugin's own layout churn at turn boundaries — every new(count, topFace)variant appends a 220–390 KB<style>(measured: ≈218–235 KB for 3 cards, ≈378 KB for 5 cards, ≈3 MB after 10 variants) that is written once and never rewritten; on top of that, the per-group rule tables (style-step-cards/style-step-files) are rewritten wholesale whenever their content changes. - Metrics read surface (read-only official data, no membership recomputation):
node.location.turn(TurnLocation: start/end/status/reason/steps) + turn data store (get('turn-tail')official aggregatetokenUsage,get('turn-process')official spec) + per-stepdata.get('assistant-step')(usage /finalNode.timing). The snapshot is subscribed viauseChat(a mergedSessionStandardPropsmember) to drive re-renders; the step data store is independent of node visibility, so hidden tool-only steps are not missed. - Poker step skin (pure CSS): official
ChatGroupSeat/ProcessGroupHeaderrender the step groups and titles as-is; the plugin CSS hides the official icon content insidedata-step-process-iconand renders a poker card via::before+ CSS mask, with the suit derived fromdata-process-activitythroughACTIVITY_SUIT(JS mapping → CSS rules generated from the same source). Gate:<body data-tf-step-skin="poker">. No MutationObserver, no React root appended to the official header, no plugin-owned step open state. - Conflict-yielding registration: before registering, the plugin probes whether
priority: -1for the same key is taken (ctx.slots.entries) and yields to the next free value (official0is always reserved) with aconsole.warn. - Soft degradation on registration errors (never breaks DSH boot): all slot
registrations go through one pipeline that catches both inject-declaration waits and
register errors; a single failed entry only skips itself, logs a
console.warnand shows one neutral degradation Toast. The host-half skill registration is equally double-guarded. - Settings are fully independent: the plugin never reads — let alone writes — the
official
transcriptViewfield (locked by a source-level architecture-guard test). Plugin settings live inlocalStorage['dsh-turn-fold:settings'](field visibility, icon style, step skin); icon packs still uselocalStorage['dsh-turn-fold:icons']. - Locale follows the UI: copy reads
document.documentElement.lang(set bydsh-client-localewhen DSH switches language), falling back to browser languages.
Notes
- Compatibility target: the DSH 0.1.7-rc.1 / rc.2
conversation.chat.node+turn-processowner-state contract. The full contract surface exists from 0.1.7-rc.1 onward (TurnProcessOwnerProps, the reactiveuseTurnDatahook,ChatNodeStore.turnDataSource— verified against the rc.1 sources); rc.1 is the live-host-verified floor and rc.2 was re-verified by per-package contract diff. Older versions are no longer declared compatible — a dormant install does not count as compatibility; compatibility means the plugin's core features really work. If a DSH upgrade changes the contract above, this plugin may need a matching maintenance release (plugin maintenance, never source patches). - Declared host requirement:
engines.dsh=>=0.1.7-rc.1 <=0.2.0-rc.2inpackage.json. The plugin marketplace (dshmarket) reads this field from the npmlatestmanifest to show the badge and to block updates that certainly cannot satisfy it (the DSH host itself does not read it). The range is closed and locked to verified host versions: after each DSH upgrade, re-verify the contract before raising the ceiling in a release. - Integration dependency list (check against DSH upgrades; audited via a
contract-by-contract diff of 0.1.7-rc.2
787b746b80↔ 0.2.0-rc.2c1b47e41fc: the Public/stable contracts are byte-identical across both releases):- Cordis activation:
exports.inject = ["slots"]+ctx.inject(["slots"], …)(the client slots service and keyed-slot registration semantics; the officialboot-clientis unchanged); - Public/stable: the
conversation.chat.nodekeyed slot (turn-processkey +TurnProcessOwnerPropsowner state);TurnLocation(start/end/status/steps), the step data store (assistant-stepusage/timing) and the turn data store (turn-tail/turn-process) — all from the officialdsh-client-ui-chat/dsh-client-ui-conversationcontracts; - Soft visual dependency:
data-step-process-icon/data-process-activity(step skin only; failure = skin disappears, official icons and folding stay intact); - Soft visual dependency (running state, dual contract): the official
TextShimmer-rendered shimmer attribute (carried by theChatGroupSeattitle while!data.closed). The attribute name evolved across official versions — both are real official historical contracts (source evidence):- DSH 0.1.7 (release commit
787b746b807df83776957875683b8853c862ca2c,TextShimmer.tsx:data-text-shimmer={active || undefined}) →data-text-shimmer="true"; - DSH 0.2.0+ (master,
TextShimmer.tsx:data-shimmer={active || undefined}) →data-shimmer="true". The plugin supports both: either one present → Running Step Poker animation (:has()matching swaps the card mask to the five-face rotation SVG rotated 35.5° — ♠ ♥ ♦ ♣ + the DeepSeek whale as five equal faces, one rotation every 0.8s, a 4s loop with dynamic knockout of the lower card, the whole animation rotated around the icon centre); both absent → completed two-state fallback (collapsed five-suit stack / expanded fan). Used only to identify running steps for the poker rotation animation; when the official turn/step closes the attribute disappears, the animation rule stops matching, and the card settles with zero JS. Whether or not the visual hooks fail, Step Fold / Tool / Think / Turn Fold and page stability are never affected;
- DSH 0.1.7 (release commit
- Soft style injection (non-ideal, recorded as-is): the plugin injects minimal
<style>elements intodocument.head— base styles, the step skin, the per-group step-count overlay rules, the per-group completed-face assets (one element per(count, topFace)variant, written once), and the per-group step file-list rules (its own element, decoupled from the skin gate; the legacy Step layer adds one more). As of the current DSH master (21638c5631) there is no style-registration API for plain-JS client plugins (the onlycreateElement('style')in the tree belongs to the web app itself), while the step skin must target the official DOM inside the official h
- Cordis activation:
…
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.