dsh-style-tweaks 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.en.md +27 -15
  2. package/README.md +27 -15
  3. package/lib/client.js +1922 -666
  4. package/lib/client.js.map +1 -1
  5. package/lib/config.js +82 -5
  6. package/lib/config.js.map +1 -1
  7. package/lib/types/client/i18n.d.ts +21 -5
  8. package/lib/types/client/i18n.d.ts.map +1 -1
  9. package/lib/types/client/index.d.ts.map +1 -1
  10. package/lib/types/client/panel.d.ts.map +1 -1
  11. package/lib/types/client/settings-client.d.ts +1 -0
  12. package/lib/types/client/settings-client.d.ts.map +1 -1
  13. package/lib/types/client/settings-nav-icon.d.ts +1 -0
  14. package/lib/types/client/settings-nav-icon.d.ts.map +1 -1
  15. package/lib/types/client/tweak-config.d.ts +11 -1
  16. package/lib/types/client/tweak-config.d.ts.map +1 -1
  17. package/lib/types/client/tweak-types.d.ts +6 -1
  18. package/lib/types/client/tweak-types.d.ts.map +1 -1
  19. package/lib/types/client/tweaks/code-block-flush-top.d.ts.map +1 -1
  20. package/lib/types/client/tweaks/composer-dock.d.ts +7 -0
  21. package/lib/types/client/tweaks/composer-dock.d.ts.map +1 -1
  22. package/lib/types/client/tweaks/legacy-running-header.d.ts +5 -0
  23. package/lib/types/client/tweaks/legacy-running-header.d.ts.map +1 -0
  24. package/lib/types/client/tweaks/legacy-stats-line.d.ts.map +1 -1
  25. package/lib/types/client/tweaks/locate-current-session.d.ts +1 -1
  26. package/lib/types/client/tweaks/locate-current-session.d.ts.map +1 -1
  27. package/lib/types/client/tweaks/pills-cache-hit-decimals.d.ts +3 -2
  28. package/lib/types/client/tweaks/pills-cache-hit-decimals.d.ts.map +1 -1
  29. package/lib/types/client/tweaks/plugin-panel-scroll.d.ts +3 -0
  30. package/lib/types/client/tweaks/plugin-panel-scroll.d.ts.map +1 -0
  31. package/lib/types/client/tweaks/registry.d.ts.map +1 -1
  32. package/lib/types/client/tweaks/running-status.d.ts.map +1 -1
  33. package/lib/types/client/tweaks/sidebar-session-count.d.ts +9 -0
  34. package/lib/types/client/tweaks/sidebar-session-count.d.ts.map +1 -0
  35. package/lib/types/client/tweaks/turn-count-stream.d.ts +12 -0
  36. package/lib/types/client/tweaks/turn-count-stream.d.ts.map +1 -0
  37. package/lib/types/client/tweaks/turn-live-speed.d.ts +4 -0
  38. package/lib/types/client/tweaks/turn-live-speed.d.ts.map +1 -0
  39. package/lib/types/client/tweaks/turn-process-counts.d.ts.map +1 -1
  40. package/lib/types/client/tweaks/turn-speed-stream.d.ts +4 -0
  41. package/lib/types/client/tweaks/turn-speed-stream.d.ts.map +1 -0
  42. package/lib/types/client/tweaks/turn-tally.d.ts +36 -0
  43. package/lib/types/client/tweaks/turn-tally.d.ts.map +1 -0
  44. package/lib/types/client/tweaks/turn-time-pill.d.ts.map +1 -1
  45. package/lib/types/config.d.ts +166 -19
  46. package/lib/types/config.d.ts.map +1 -1
  47. package/package.json +26 -26
package/README.en.md CHANGED
@@ -22,23 +22,26 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) web
22
22
  - **Stable session titles (default off)** — since 0.1.6-alpha.2, hovering a session row smoothly slides an overlong title to its end to reveal the clipped tail, and it snaps back on leaving. This restores the pre-0.1.6-alpha.2 behaviour: the title stays put with its ellipsis at all times; the row's hover card (pause a moment) still shows the full title.
23
23
  - **Always show turn navigation (default off)** — DSH hides the turn-navigation rail once the chat column's content box reaches 900 px (a container query in `TurnNavigator.module.css` measured against the chat scrollport). Widening the right sidebar is exactly what triggers it: the center column may be squeezed down to 400 px, so the rail disappears over almost the panel's whole usable range. With this ON the rail stays at every chat width; at narrow widths it sits in the scrollport's right gutter and its hover preview covers part of the transcript.
24
24
  - **Hide session hover buttons (default off)** — hovering a session row floats the Archive and Pin icon buttons into its trailing cell, with the archive button right under the pointer (one click archives). This keeps them off the row: while hovered it shows only the host's own `...` menu button, where both actions still live.
25
- - **Flush code-block top (default on)** — removes the 16 px gap above highlighted code blocks so the code sits flush with the preceding paragraph, list item, or heading.
25
+ - **Flush code-block top (default on)** — collapses the padding above the first line of code *inside* a highlighted code block, so the code hugs the card's header row. Only the inside of the box is touched: the spacing between the code block and the text before and after it, the inner bottom and lateral insets, and the code's alignment with its header are all pixel-identical to the tweak-off state.
26
26
  - **Project running indicator (default on)** — puts an animated running dot into the sidebar. While it is on, every running dot in the app is redrawn as ZCode's spinner: eight evenly spaced spokes turned at a constant 1s per revolution, independent of the system's reduced-motion setting — DSH 0.1.7 freezes all of them into still rings there, so the tweak draws its own (the two injected dots and the title's own alike; see below). One sits on the right side of each project directory (so a busy conversation stays visible even when its group is collapsed), and one fills a session row's status slot whenever that session holds a background job the app's own dot does not cover (when the app does render its own dot there, the tweak steps aside rather than doubling up). A session counts as busy when it is executing a turn, when a background job is still open under it (a `run_in_background` shell command, lit from launch until the job settles), or when a running subagent sits below it in the lineage.
27
- - **Locate current session (default on)** — adds a "locate" button to the left of the native search button in the sidebar's "Workspaces" section header. Clicking it expands the current session's workspace directory (if collapsed) and its "Show {n} more sessions" overflow, then scrolls the session into the sidebar's visible area. Disabled with the tooltip "Open a session first" when no session is open. The button does not appear while the sidebar is collapsed to its icon rail — there is no section header (and so no search box) in that state.
27
+ - **Locate current session (default on)** — adds a "locate" button to the left of the native search button in the sidebar's "Workspaces" section header. Clicking it expands the current session's workspace directory (if collapsed) and its "Show {n} more sessions" overflow, then scrolls the session into the sidebar's visible area. Disabled with the tooltip "Open a session first" when no session is open. The button does not appear while the sidebar is collapsed to its icon rail — there is no section header (and so no search box) in that state. The scroll lands in a single frame by default; turning on the custom row count in the Sidebar session list section and its "Expansion animation" option gives it the 280 ms glide instead (see the next entry).
28
28
  - **Scrollable settings nav (default on)** — lets the settings dialog's left menu scroll when its entries outgrow the panel, instead of silently clipping the ones at the bottom (the panel has a fixed height and `overflow: hidden`; stock CSS only gave the right content column a scroll treatment). The scrollbar is an overlay-style thin strip: parked in the rail's spare right padding so it never squeezes the menu, and visible only while the list is actually scrolling, fading out ~0.8 s after scrolling stops.
29
+ - **Plugin panel keeps its place on the way back (default on)** — two navigation repairs to the plugin management panel (the page the "Plugins" entry in the left rail opens), under one switch. First, **the memory serves going back only**: returning one level up (a component's settings page to its plugin's page, a plugin's page to the plugin list) puts you where you left that page instead of at the top, while *entering* a page applies no memory and opens it at the top (measured: entering the mnemon page from the list at 900 lands on its top in the first frame, and the rows arriving later leave the position untouched), **and an offset of 0 is a position on the going-back side too**: returning to a page you had left at its top has to write that 0, or it lands wherever the swap's clamp stopped — measured on a harness panel (a 600 px port: list at 0 → a 2200 px page scrolled to 1500 → a return to a list whose content then holds 440): the old code wrote nothing at all and stopped at 440, the bottom of the list, where the same sequence now writes 0 once and stops at 0; a view is identified by the attribute the host writes on its own root (`data-plugin-detail` / `data-plugin-item-detail` / `data-plugin-row-detail`, the list being the one view marked by its `pageHead` instead), so no class has to be guessed and two plugins cannot collide. Second, a detail page's header (its crumb, the plugin's icon and its actions) is pinned to the top of the panel while you scroll (`position: sticky; top: 0`), so the way back never has to be scrolled to; its background is the panel's own `--dsw-alias-bg-base` (the scrollport is transparent and that token paints the frame behind it — verified pixel-identical to the panel in both light `#fff` and dark `#151517` on 0.2.0-rc.2), and the 12 px of breathing room under a pinned bar is paired with an equal negative margin, so the resting layout is byte-identical to stock. Three findings that are not obvious and each cost a wrong implementation: **the offset cannot be read at the moment of the swap** (the browser clamps at layout, before the MutationObserver's microtask — a probe read a view that had been at 900 back as 0), so it is recorded from capture-phase `click` / `keydown` on the panel, which run before React's handler and are therefore the last instant before the clamp; **a script's `scrollTop` write fires no `scroll` event** (writing 500 produced zero events over the following 400 ms), which leaves the `scroll` listener responsible only for real reading — and which is also what lets a still-running restore tell the browser's one clamp apart from your own hand on the wheel and stand down; and **Chromium's scroll anchoring drags a just-restored position away again** (the same sequence restores 1023 with anchoring off and lands on 1316 with it on), so `overflow-anchor` is turned off for this one scrollport. The map lives in module scope (neither `detach` nor a settings-triggered re-mount of this plugin clears it) because the panel unmounts entirely on a trip to the chat — and because every settings save re-mounts all of this plugin's tweaks — and a later return still has to land right — but **opening the panel is entering, and restores nothing**.
29
30
  - **Middle-click closes sidebar tabs (default on)** — since 0.1.5 the right sidebar is a tabbed panel. A middle mouse click on any of its tabs (docked or floating) closes it, the way browser tabs behave. The close rides the host's public `ctx.sidebarRight` close face, so the native rules stay in force: the guide standing as the sole docked tab cannot be closed, and closing the last docked tab collapses the column exactly as its ✕ does. Middle-click autoscroll is suppressed over the strips, and a held middle button can no longer drag or float a tab. On pre-0.1.5 hosts the tweak stays inert.
30
31
  - **Legacy stats line (default off)** — since DSH 0.1.5 the stats under the input box are two icon pills that open dialogs; this tweak brings back the pre-0.1.5 centered text line (turns/steps · timings · speed · cache hit · tokens). It reads the same durable projections (`sessionStats` / `tokenUsage`) so every figure matches the pills; an overlong line truncates with an ellipsis and reveals itself on hover, and turning the tweak off hands the row straight back to the pills.
31
32
  - **Cache hit with two decimals (default off)** — shows the composer stats' cache-hit share with two decimal places (e.g. `87.35%`) instead of integer rounding; the token-usage dialog follows. Applies to both presentations — the new icon pills and the legacy text line alike; with both toggles on, the legacy line renders and takes the flag.
32
33
  - **Turn time and speed (default off)** — through 0.1.6 a settled turn ended with two click-open readings in its action row: usage and time, each opening its own dialog. 0.1.7-alpha.1's performance-and-usage preference dropped the time one (`TurnTimePanel`, `message.ranFor` and the whole `message.turnTime.*` vocabulary went with it). This puts it back right of the usage reading (the row reads usage → time → clock again, as in 0.1.6), with its Turn time and speed dialog: the turn's total run time plus output speed (TPS) and time to first token (TTFT), the last two rebuilt from the model stream embedded in the session log (cold sessions have not replayed that stream since 0.1.5, so this is the step that brings those two numbers back). Both figures come from the durable log, so history loads show them too; a turn whose start/end the loaded window does not hold shows no reading, leaving the 0.1.7 row untouched.
33
34
  - **Call counts on process groups (default on)** — a turn's reasoning and tool calls merge into one folded process group. Through 0.1.6 its header was a tally — tool calls and messages, plus subagents when the turn spawned any; 0.1.7-alpha.1 replaced it with the elapsed time (or the state wording while running / failed / stopped). This restores the 0.1.6 tally after the time, so both halves show at once: the counts grow while the turn runs (a new tool call or message lands immediately), the failed / stopped / running headers read the same way, and a turn with nothing to count is left as shipped. Set the switch off to keep the stock 0.1.7 header.
34
- - **Blue running status at the tail (default on)** — through 0.1.6 an open turn always ended with a separate brand-blue shimmer line, "深度求索中 / Deep diving", immediately above the composer; after fifteen seconds its elapsed clock appeared beside it. 0.1.7's grouped-work-details refactor removed that React component and merged the running wording into the process-group header. The running group stays open, so its header remains above the whole reasoning/tool run and moves away from the composer as calls accumulate. This restores a visual-only tail row using the host's current locale and live clock. The process header and accessibility announcement stay host-owned, so nothing is announced twice; pre-0.1.7 hosts already render their own line and remain unchanged.
35
+ - **Elapsed time above the process rows (default off)** — through 0.1.7-rc.2 a running turn drew its process group immediately, headed by a disclosure that was disabled (an open turn is always expanded), so the row read as a heading rather than a control: `深度求索中,用时 1小时50分26秒`, sitting directly above the tool rows and ticking once a second, with no chevron. 0.2.0-rc.1 stopped drawing it mid-turn and moved the running label to a blue row at the live tail, where growing tool output no longer pushes it away from the composer. This puts the 0.1.7 heading back above that turn's tool rows (carrying the 0.1.6 counts as well, with the previous item on) and times it from this plugin's own dictionary (0.2.0-rc.2 replaced the duration templates, so borrowing them prints the key names). It is not clickable, has no chevron, and is a `div` rather than the host's button. **The heading arrives with the turn's first row of work, not with the turn**: before that there is nothing under it to head, and the blue tail row is already saying the turn is running — the screen is exactly what it would be with this off. The tail row is never hidden or restyled by this tweak, so from the first row on the two coexist. Once the turn ends only the host's own header remains.
35
36
  - **Opaque stat dialogs (default off)** — 0.1.7-alpha.1 swapped the card material for a translucent, frosted one (the shared `--dsw-specific-menu` fill went from 0.1.6's opaque layer-3 to a half-transparent color paired with a 40px backdrop blur), which is harder to read a wall of figures through. This switches **only** the click-open stat dialogs back to 0.1.6's opaque fill, five in all: the composer's session-statistics and token-usage cards, the turn-tail per-turn usage and time pills, and the context-occupancy panel. Every other surface — the session and workspace "..." menus, the model selector, the composer's / and @ menus, the todo / queue / dock panels — deliberately keeps 0.1.7's frosted material; the dark theme uses the theme's own layer-3, so no separate value is needed.
36
37
  - **No context meter hover info (default off)** — the context meter button under the input box (a ring gauge plus percentage, new in 0.1.6-alpha.2) floats a "N% of context used" tooltip when hovered. With this on, the hover tooltip no longer appears; the button's own hover highlight and its click-open breakdown dialog are untouched, so the full figures stay one click away. Hosts without that button leave this inert.
37
38
  - **Closable workspaces (default off)** — hide a Workspace from the sidebar list and the New Session picker instead of deleting it: the host registry row, its session account, the files on disk and every session log are all kept, the Workspace and every session keep their account position, and **its sessions are hidden with it** (they never fall into Ungrouped, and search stops listing them); restoring the Workspace brings the whole group back. Restore it by re-adding the same folder (the host resolves it by canonical path and returns the same Workspace) or from the "Closed workspaces" list in the Style tweaks settings panel (each entry reveals its folder on hover). The entry sits in each Workspace row's `...` menu, above "Delete workspace", behind a confirmation that spells out what is kept. Three boundaries: a closed Workspace's manual session order is not preserved (sessions show in host account order); closing the Workspace a session is currently open in hands you straight to a new session (in the most recent visible Workspace); and "New session" triggered from a session inside a closed Workspace falls back to the most recent **visible** Workspace (the host's own fallback).
38
39
 
39
40
  - **Custom history page size (default off)** — DSH chooses its own request size (the current 0.1.7 line uses 500 for a cold open and "Load earlier", with a separate 200-message lower bound for turn jumps). When ON, every applicable request uses the configured size **exactly** (50–1000, default 500), whether that is larger or smaller than DSH's native value; the rewrite happens on the unary `session/page` used by "Load earlier" and turn jumps, plus the optional `session/follow` open frame for a session's first screen. The host's turn-window minimum is synchronized to the same value, so a large page is not cut short after only two turns by the native 50-message boundary. Three boundaries: **the configured value is exact**, rather than only a lower bound; **the first screen has its own "Apply to session open too" toggle (default ON)** — turning it off leaves DSH's native first screen untouched while the size still applies to later pagination; **the master switch off fully restores DSH's own behaviour**, keeping the saved size for the next enable.
40
41
 
41
- > Field and default reference (maintained by the Settings panel). **On DSH 0.1.7+ the live copy is the `value` object of `<profile>/.dsh-style-tweaks/store.json`** — same field names as below, plain JSON, hand-editable and **picked up on the next read** (reopen/refresh the Settings panel); this plugin's `config` row in `cordis.patch.yml` is no longer read and exists only as the seed for a missing store file (delete that file to re-seed from it). Older hosts keep the values in the settings namespace. Because the store is partial, an omitted field uses its current default; existing Desktop/Web profiles without `turnProcessCounts` therefore show the counts by default, and an explicit `false` keeps the stock header.
42
+ - **Custom sidebar session count (default off)** — DSH folds every Workspace's session list to five rows and grows it five at a time; both numbers are a `useState` inside the host's workspace-browser component (`COLLAPSED_SESSION_LIMIT`), and the persisted view store keeps no key for either, so no setting, slot or store action reaches them from outside. When ON the plugin owns the list: a Workspace shows the configured "rows shown initially" at rest, and each press of "show more" adds the configured step (both floored at the host's own 5 and uncapped above it; the steppers move one row per click). **a provisional New Session row (one that has not sent its first message yet) never counts against those rows**: it stays visible and cannot push a real session out of the list; as soon as a session sends a message — including a history session you continue — it counts normally and folds away past the block like any other row. It does not replace the host's control — it drives the one the host already renders: the plugin presses the host's overflow button until enough rows are rendered, hides the surplus behind a marker attribute of its own, and puts its own button where the host's was, carrying the host's own `workspace` wording (`sessions.expand` / `sessions.collapse`) and the honest remainder as the figure; once everything is shown the button reads the host's "collapse" and trims back to the initial count (the host's own fold can only express five, so collapsing is the plugin's job). Three boundaries: **the selected session row is never hidden and never out of place** (opening a session — including one found through the host's own search, or one the locate button found — marks the row `aria-selected="true"`; the trim only takes rows BELOW it, so everything above it stays visible and the session's place on screen is its real place in the list — and the count that rule opens up is **rounded up to a configured size and written back into the group** — with 7/7, being in the ninth session shows 14 rows, not 9, and the fifteenth shows 21 — so clicking an earlier session afterwards does not fold the list back to the configured count, and folding the Workspace resets the part "show more" built while keeping the size the selected row needs (a reopened group renders only the host's own five rows, so a dropped requirement could never be measured again), while **changing a setting in this section leaves the lists on screen alone** — every save merely re-mounts the plugin, so an expanded Workspace keeps its width, the new counts apply at once to the lists nobody expanded, and the rest follow them on their next fold); **growing is never hidden** (the host's own row glide already presents the 5 → 10 → 15 steps as one growth, and a hide short enough to be one frame still reads as a flicker, so the plugin never adds a `visibility` toggle); and **steady state costs nothing** (a per-group fingerprint lets a sweep skip groups whose settled state has not moved, so a sidebar whose rows keep updating for other reasons is not recomputed again and again). The same section carries "Expansion animation" (default off): with it on, the rows a reveal brings back fade in and grow the list over 160 ms (the hide direction is always instant), and the locate button's scroll glides over 280 ms; off — the default — makes both land in a single frame. It is the opt-in alternative for a machine set to reduce motion (like this one), where the host's own row fades and the browser's smooth scrolling both give out and stack the reveal and the scroll into one teleport — but whether to smooth is a preference, so the plugin does not force it. The locate button's scroll follows this switch too, but both are read only while the section's master switch (the custom row count) is on: with it off the sidebar is DSH's own again and the locate scroll lands in a single frame as well (the value is kept, so switching the count back on restores your choice). OFF (default) restores DSH's own folding and its own button outright, forgetting every remembered expansion so a later switch-on starts from the configured counts; hosts without these structural anchors leave the tweak inert.
43
+
44
+ > Field and default reference (maintained by the Settings panel). **On DSH 0.1.7+ the live copy is the `value` object of `<profile>/.dsh-style-tweaks/store.json`** — same field names as below, plain JSON, hand-editable and **picked up on the next read** (reopen/refresh the Settings panel); this plugin's `config` row in `cordis.patch.yml` is no longer read and exists only as the seed for a missing store file (delete that file to re-seed from it; the row in this machine's profile normally carries no `config` block at all, which is expected — while the store exists that block takes no part in reads or writes). Older hosts keep the values in the settings namespace. Because the store is partial, an omitted field uses its current default; existing Desktop/Web profiles without `turnProcessCounts` therefore show the counts by default, and an explicit `false` keeps the stock header.
42
45
 
43
46
  ```yaml
44
47
  style-tweaks:
@@ -53,6 +56,11 @@ style-tweaks:
53
56
  historyPageSizeEnabled: false # default false; true sends the size below on every history request (the panel's master switch)
54
57
  historyPageSize: 500 # 50–1000; shown/effective only while historyPageSizeEnabled is on
55
58
  historyPageSizeColdStart: true # default true; a session's first screen uses that size too
59
+ # Sidebar session list
60
+ sidebarSessionCountEnabled: false # default false; true lets the counts below own each Workspace's session-list folding
61
+ sidebarSessionInitialCount: 5 # ≥ 5; session rows a Workspace shows at rest (shown/effective only while sidebarSessionCountEnabled is on)
62
+ sidebarSessionExpandStep: 5 # ≥ 5; rows one "show more" press adds (same gate)
63
+ sidebarSessionExpandAnimation: false # default false (reveals and the locate scroll land in a single frame); true eases reveals over 160ms and the locate scroll over 280ms (shown and in effect only while the master switch is on: with it off both land in a single frame; the value is kept)
56
64
  # Tweaks
57
65
  stableSessionTitle: false # default false; true keeps session titles put with their ellipsis on hover
58
66
  hideSessionHoverActions: false # default false; true keeps the Archive / Pin hover buttons off session rows (the "..." menu keeps both)
@@ -61,12 +69,13 @@ style-tweaks:
61
69
  projectRunningIndicator: true # default true; false disables the tweak
62
70
  locateCurrentSession: true # default true; false hides the sidebar locate button
63
71
  settingsNavScroll: true # default true; false disables the settings-nav scrolling
72
+ pluginPanelScroll: true # default true; false disables both plugin-panel halves (no remembered offsets at any level, no pinned page header)
64
73
  sidebarMiddleClickClose: true # default true; false disables middle-click tab closing
65
74
  legacyStatsLine: false # default false; true swaps the pills for the 0.1.2 text line
66
75
  pillsCacheHitDecimals: false # default false; true shows the cache hit with two decimals (pills and legacy line)
67
76
  turnTimePill: false # default false; true restores the turn-time reading 0.1.7 removed (next to the usage reading, with its "Turn time and speed" dialog)
68
77
  turnProcessCounts: true # default true; false keeps the folded process group's stock 0.1.7 header
69
- runningStatus: true # default true; restores 0.1.6's blue live status above the composer on 0.1.7
78
+ legacyRunningHeader: false # default false; true gives a running turn back its 0.1.7 process-group heading (arrives with the first row; the blue tail row is untouched)
70
79
  opaqueStatDialogs: false # default false; true gives the five readout stat dialogs 0.1.6's opaque fill (menus and panels keep 0.1.7's frosted material)
71
80
  legacyContextMeter: false # default false; true puts the context ring back inside the input card (0.1.6-alpha.2+)
72
81
  contextPillNoTooltip: false # default false; true suppresses the context meter button's hover tooltip (0.1.6-alpha.2+; the row hides while the previous toggle is on — the stored value still applies)
@@ -90,8 +99,8 @@ The package spec after `add` is forwarded to pnpm verbatim, so versions can be
90
99
  pinned — `@version` for the npm package, `#tag` for the GitHub source:
91
100
 
92
101
  ```bash
93
- dsh plugin --profile web add dsh-style-tweaks@0.1.5 # pin the npm version
94
- dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.5 # pin a git tag
102
+ dsh plugin --profile web add dsh-style-tweaks@0.1.7 # pin the npm version
103
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.7 # pin a git tag
95
104
  ```
96
105
 
97
106
  Restart DSH web once after installing (bundle plugins are scanned at process start).
@@ -249,7 +258,7 @@ source change has to be copied over — exactly the step the `link:` setup spare
249
258
  ```bash
250
259
  pnpm build
251
260
  pnpm pack # produces dsh-style-tweaks-<version>.tgz (gitignored; do not commit)
252
- dsh plugin --profile web add ./dsh-style-tweaks-0.1.5.tgz
261
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.7.tgz
253
262
  ```
254
263
 
255
264
  **Restart `dsh web` once** after installing (bundle plugins are scanned at process start).
@@ -294,7 +303,7 @@ path dependency:
294
303
 
295
304
  ```bash
296
305
  dsh plugin --profile web remove dsh-style-tweaks
297
- dsh plugin --profile web add ./dsh-style-tweaks-0.1.5.tgz
306
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.7.tgz
298
307
  ```
299
308
 
300
309
  The only difference between the two setups is whether the artifact lands in place automatically: both
@@ -309,15 +318,18 @@ to verify the published package itself.
309
318
  - **Column-width engine** (`src/client/conversation-width.ts`) writes the `--dsh-chat-user-width` CSS variable and hides DSH's native `[data-width-handle]` drag strips while plugin width control is on; the chosen px is also mirrored into the localStorage slot the native handles read, so flipping the switch round-trips cleanly.
310
319
  - **Tweak registry** (`src/client/tweaks/registry.ts`) centralises each tweak's metadata (id, settings field name, default value, i18n keys); adding a new tweak means appending one entry here and dropping a new injector file under `src/client/tweaks/`.
311
320
  - **Project running indicator** (`src/client/tweaks/project-running-indicator.ts`) reads session running state and directory membership from `ctx.get('sessions')` / `ctx.get('workspaces')`, and uses a MutationObserver to mount the app's own `StateDot` (mounted from `@deepseek-ai/dsh-client-ui-primitives`, but the ongoing state's look is replaced wholesale by this tweak. The host's own ongoing dot is a breathing arc on a ring, and since 0.1.7 it is frozen into a still ring under `prefers-reduced-motion: reduce` — with that preference set, **every** running dot in the app, the title's own included, simply stops. A still ring cannot say "work in flight", so every ongoing dot in the app is redrawn as ZCode's spinner: lucide's `loader` icon — eight evenly spaced spokes, radius 6..10 of a 24 viewBox, ~2 units thick, round caps, all equal, with **no** opacity trail — turned at `animate-spin`'s constant 1s per revolution, regardless of the system setting. CSS can only reach the host's `svg`, `g` and two circles, so the spokes are an SVG mask on the svg element: lucide's own `loader` path data turned into a `data:` URI (`mask-size: 100% 100%`, so any StateDot size works), with the svg itself filled `currentColor` so the colour still comes from the host's CSS module (tertiary grey); everything the host draws inside the svg is switched off (`> *`, not just today's two circles, so a host that redraws its spinner with other elements stays covered), and `:has([class*="spinnerMotion"])` keeps it to real StateDots. A mask rather than a `conic-gradient` because gradient wedges rasterise differently per angle and end up looking uneven in weight, while an SVG stroke (round caps) rasterises exactly like the icon ZCode itself draws. The override stops at that component: the host's other reduced-motion answers — tool-row sweeps, text shimmers, progress bars — keep honouring the setting) on two kinds of sidebar row: the right side of each project directory header row (`role="treeitem"[aria-expanded]`), and the status slot of a session row (`role="treeitem"` plus the CSS-Modules local name `sessionRow`) that the app left empty. "Busy" merges three sources: the session's own `SessionSummary.running` (a turn in flight); the background jobs under it — `jobsBySession` in the `sessions` store snapshot, mirrored by Session Controller's control stream, where a status of `running` or `stopping` counts (the same `isLive` predicate DSH's own `ui-jobs` session-header action uses, so a `run_in_background` command stays lit from launch to settlement instead of going dark once the turn that started it answers); and a running subagent anywhere below it in the lineage (the same upward walk as `indexSubagentDescendants`). Header rows match workspaces by group key (`ProjectRowItem`'s `props.group.key`, i.e. `GroupNode.key`) rather than by title text: `GroupNode.label` is the directory basename, so two same-named directories under different parents collapse to one shared label (the Host only rejects a colliding *rename*, not a duplicate directory name) and title matching would light both — which is exactly why two `pi-web` directories once showed the indicator together. A session row is only decorated while its slot is empty — the moment the app renders its own dot there (turn running, subagent running, approval or question pending, unviewed completion) the injected dot steps aside instead of sitting next to it. Session rows expose no plugin slot and carry no session id in the DOM, so the id is read off React's fiber chain (`SessionNodeItem`'s `props.node.id`): the walk is defensive (an unreadable id just leaves that row alone) and re-resolved every pass, because the sidebar recycles its row elements. On a host without the `jobs` service `jobsBySession` is absent or empty and the tweak quietly falls back to the two cases the app already covers.
312
- - **Locate current session** (`src/client/tweaks/locate-current-session.ts`) injects a new button into the sidebar's workspaces section header, to the left of the native search button. All anchors are i18n-safe: the search button's aria-label and the breadcrumb nav's aria-label are resolved through `ctx.locale.bind()` (the `workspace` / `conversation` namespace keys DSH itself uses), with structural class-fragment fallbacks (`searchButton`, `crumbs`) when the locale service is unavailable. Clicking it first reads the current session's **id** off the conversation header — `ConversationSessionHeader` and the header components above it all memoize the `sessionId` they render, so the nearest one walking up the fiber chain from the breadcrumb nav is the session on screen; a sidebar row keeps that same value as `props.node.id` (`SessionNodeItem`), so "the current session" and "some sidebar row" are keyed alike. `ctx.get('workspaces')` then maps the session id to its workspace **id** (works even when the workspace's sidebar group is collapsed), and the workspace row is found by that id (`ProjectRowItem`'s `props.group.key`, read off the fiber chain) rather than by exact title match — two Workspaces whose directories share a basename share a label, and a title match then opens whichever one comes first. It then pierces both collapse layers — clicking the collapsed workspace row to expand it, and auto-clicking the "Show {n} more sessions" overflow button (`[class*="sessionOverflowButton"][aria-expanded="false"]`) when the target row hides behind it — before `scrollIntoView({ block: 'center' })`, matching rows by id at every step rather than by the text they render. **As of 0.1.7-alpha.1 this no longer goes through the breadcrumb title**: that build renders the current crumb as a plain `<span class="…crumbCurrent">` instead of a `button[disabled]` (plain text is what lets the title join the desktop window's drag band, harness `92101e1a5b`), and a title was never a reliable identity anyway — two sessions may share one, and a session with no recorded ancestry is painted as its raw id. The title survives only as the fallback path used when no id can be read (`readCurrentSessionTitle` accepts both the span and the older disabled button, so 0.1.6 keeps working), and that fallback is the only thing `ctx.get('sessions')` is still needed for. The hover tooltip replicates DSH's native `<Tooltip>` primitive (fixed-position bubble, theme tokens, 500 ms delay, viewport flip) instead of the browser-native `title`. A **The mount point is verified, not inferred from "walk up two levels"**: DSH paints a **second** search button for the collapsed 56px rail — the same `searchButton` class and the same `search.sessions.aria` aria-label, only the icon is 18px instead of 14px — but it hangs straight off the workspace browser's root (rail: `root > search > searchButton`; wide: `root > sectionHeader > searchSlot > search > searchButton`). Counting two parents up from the rail's button therefore lands on `root`, not `searchSlot`, and the button gets injected into a container React owns: a stray locate icon shows up in the rail, and since React only unmounts fibers it created, that orphan survives the rail's own teardown as a child of `root` that no re-render clears — it stays over the expanded sidebar until the page is reloaded. So mounting first asserts that two levels up is a `div[class*="searchSlot"]` and bails out otherwise (the rail has no section header at all, so the button rightly does not exist there), and every sync additionally prunes any `cst-locate-btn` not sitting in a `searchSlot`, which makes the invariant self-repairing. MutationObserver watches `aria-selected` / `aria-expanded` attribute changes to keep the button's enabled state and mount in sync.
321
+ - **Locate current session** (`src/client/tweaks/locate-current-session.ts`) injects a new button into the sidebar's workspaces section header, to the left of the native search button. All anchors are i18n-safe: the search button's aria-label and the breadcrumb nav's aria-label are resolved through `ctx.locale.bind()` (the `workspace` / `conversation` namespace keys DSH itself uses), with structural class-fragment fallbacks (`searchButton`, `crumbs`) when the locale service is unavailable. Clicking it first reads the current session's **id** off the conversation header — `ConversationSessionHeader` and the header components above it all memoize the `sessionId` they render, so the nearest one walking up the fiber chain from the breadcrumb nav is the session on screen; a sidebar row keeps that same value as `props.node.id` (`SessionNodeItem`), so "the current session" and "some sidebar row" are keyed alike. `ctx.get('workspaces')` then maps the session id to its workspace **id** (works even when the workspace's sidebar group is collapsed), and the workspace row is found by that id (`ProjectRowItem`'s `props.group.key`, read off the fiber chain) rather than by exact title match — two Workspaces whose directories share a basename share a label, and a title match then opens whichever one comes first. It then pierces both collapse layers — clicking the collapsed workspace row to expand it, and auto-clicking the "Show {n} more sessions" overflow button (`[class*="sessionOverflowButton"][aria-expanded="false"]`) while the target row hides behind it, pressing once per frame until the row exists — before the locate's own 280ms ease scrolls it to the middle of the viewport (Chromium degrades `behavior: 'smooth'` to an instant jump under `prefers-reduced-motion: reduce` — measured here at 568px in a single frame — so the scroll is driven by the plugin; that ease is governed by the "Expansion animation" switch in the Sidebar session list section, and it is read only while that section's master switch (the custom row count) is on — without it the scroll skips the rAF loop and lands synchronously with `behavior: 'instant'`), matching rows by id at every step rather than by the text they render. **As of 0.1.7-alpha.1 this no longer goes through the breadcrumb title**: that build renders the current crumb as a plain `<span class="…crumbCurrent">` instead of a `button[disabled]` (plain text is what lets the title join the desktop window's drag band, harness `92101e1a5b`), and a title was never a reliable identity anyway — two sessions may share one, and a session with no recorded ancestry is painted as its raw id. The title survives only as the fallback path used when no id can be read (`readCurrentSessionTitle` accepts both the span and the older disabled button, so 0.1.6 keeps working), and that fallback is the only thing `ctx.get('sessions')` is still needed for. The hover tooltip replicates DSH's native `<Tooltip>` primitive (fixed-position bubble, theme tokens, 500 ms delay, viewport flip) instead of the browser-native `title`. A **The mount point is verified, not inferred from "walk up two levels"**: DSH paints a **second** search button for the collapsed 56px rail — the same `searchButton` class and the same `search.sessions.aria` aria-label, only the icon is 18px instead of 14px — but it hangs straight off the workspace browser's root (rail: `root > search > searchButton`; wide: `root > sectionHeader > searchSlot > search > searchButton`). Counting two parents up from the rail's button therefore lands on `root`, not `searchSlot`, and the button gets injected into a container React owns: a stray locate icon shows up in the rail, and since React only unmounts fibers it created, that orphan survives the rail's own teardown as a child of `root` that no re-render clears — it stays over the expanded sidebar until the page is reloaded. So mounting first asserts that two levels up is a `div[class*="searchSlot"]` and bails out otherwise (the rail has no section header at all, so the button rightly does not exist there), and every sync additionally prunes any `cst-locate-btn` not sitting in a `searchSlot`, which makes the invariant self-repairing. MutationObserver watches `aria-selected` / `aria-expanded` attribute changes to keep the button's enabled state and mount in sync.
313
322
  - **Scrollable settings nav** (`src/client/tweaks/settings-nav-scroll.ts`) turns the settings dialog's left nav list into the scroll container of the nav rail: DSH's settings panel (`SettingsRoot`) has one fixed height with `overflow: hidden` and only gave the right content column (`.options`) an `overflow-y: auto`; the left `.navList` has neither `min-height: 0` nor an overflow treatment, so extra entries spill out and get clipped. Two CSS rules plus a small JS driver do it: the base rule `flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: 12px; margin-inline-end: -12px` makes the list scrollable and parks the classic scrollbar in the nav rail's full 12px right padding gutter (8px gap + 4px strip), so the thumb floats over the spare padding 8px clear of the entries; a cell cap `max-width: 164px` (DSH's stock 188 nav − 2×12 padding) pins the entries to stock width in every state — bar present or not, hovering or scrolling — and `::-webkit-scrollbar { width: 4px }` is two notches thinner than DSH's global skin. The show timing is JS-driven: the first cut used `:not(:hover)` to blank the thumb, but Chromium does not reliably repaint custom scrollbar pseudo-elements when only the host's `:hover` state changes (in practice the bar appeared when clicking a nav cell — the React re-render forced the repaint), so the driver listens for `scroll` events on the list instead: scrolling adds a `cst-nav-scroll-show` class that reveals the thumb (coloured from the panel's inherited l2 tokens), and the class is removed ~0.8 s after scrolling stops; a MutationObserver re-attaches the listener whenever the dialog unmounts and reopens. The selector pairs the modal structure with a CSS Modules class fragment (DSH hashes classes as `[hash]_[local]`; the `navList` local name is unique across DSH), and needs no `!important`.
323
+ - **Plugin panel keeps its place** (`src/client/tweaks/plugin-panel-scroll.ts`): the plugin management panel is the `main`-slot panel keyed `plugins` that `ui-plugin-manager` registers, and its whole UI is one scrollport with four views swapped through a navigation store (`navigation-store.ts`: `list` / `package` / `item` / `row`): `<section class={css.page} data-plugin-panel>` is the `overflow: auto; height: 100%` scrollport; the list view renders `<header class={css.pageHead}>` plus `<section class={css.group}>` rows, and the three detail views render `<div class={css.detail} data-plugin-…-detail>` → `<div class={css.detailTop} data-window-drag>` holding the crumb, the icon and the actions. **The panel nests three deep** — list, then a plugin's page, then one component's settings page — and on a bundle with a dozen-plus components (mnemon ships sixteen) every level is long enough to scroll on its own (measured ~1800 / ~2700 / ~1100 px of scrollport against an 860 px viewport), so the second level is remembered too; remembering only the list reproduces the same bug one level down, and the third level's own offsets are recorded but never applied — nothing can return to them — which is what keeps "back restores, forward does not" free of exceptions. **A view is identified by the attribute the host writes on its own root**: `data-plugin-detail="<package>"`, `data-plugin-item-detail="<official id>"`, `data-plugin-row-detail="<package>#<row id>"`, all on the scrollport's direct child (the same attributes the host's own cross-plugin navigation reads); the list has none of them but renders that `pageHead` — so identifying a view needs no class guess at all, and only the list uses a `[hash]_[local]` fragment, which is safe for a narrower reason than uniqueness — `dsh-client-ui-schedule` declares `_pageHeading`, which `[class*="_pageHead"]` also matches; what protects this one is `:scope >` pinning it to the plugin panel's own direct children, where only the host's list header can match. **Why the position is lost** (measured on 0.2.0-rc.2): the scrollport is *not* rebuilt across a view change — the same `<section>` node answers `querySelector` before and after (it does unmount entirely when you switch to another global panel) — so the loss is pure CSS: with `.page { height: 100%; overflow: auto }` the browser clamps `scrollTop` to what the new view can hold the moment the content swaps, which is 0 for a short page. Each view's offset is therefore remembered separately: the panel carries **a passive `scroll` plus capture-phase `click` and `keydown`** recorders and a **MutationObserver over its direct children only** that catches the view swap and re-applies a page's *own* offset **only when the swap is a step back up** (the crumb); entering a page applies no memory and puts it at the top on the first frame, and an offset of 0 is written back like any other — it is a position, and the one the swap's clamp is guaranteed to have replaced where a page is returned to at its top (the 884 measurement below is that hole). **Why three recorders** is the least obvious part of this, and two measurements each killed one obvious design: **the offset is already gone by the time the observer runs** — the clamp happens at layout, ahead of the observer's microtask, and a probe registered alongside the tweak read a view that had been at 900 back as 0, so "read it on the way out" does not exist; and **a programmatic `scrollTop` write fires no `scroll` event** — `panel.scrollTop = 500` left the property at 500 and produced zero scroll events over the following 400 ms (wheel, trackpad, keyboard and scrollbar drags all do fire). The two **capture-phase** listeners are what carries the feature: a navigation is always a press, and capture on the panel runs before React's own handler (React 18 binds at the root container, and the root's bubble phase is later in the event's journey than the panel's capture phase), i.e. the last instant before the clamp; `keydown` also covers a button driven by Space, whose click is only synthesized on key-up. The `scroll` listener covers what the captures cannot see — a view that changes without a press, such as the host resetting its navigation store when the panel is deactivated. **The first restore runs synchronously inside that observer callback** (a microtask, ahead of the next paint), so the swap's first frame already carries the offset; a rAF-only restore flashes the top first. Later frames are still required, because a view's own rows arrive in **bursts** and the panel may be too short to hold the offset on the first frames. "Still growing" is therefore measured as a **quiet spell** rather than as "grew since the last frame": on mnemon's package page the last batch of rows landed ~140 ms after the swap (scrollport 2394px → 2687px at t=153ms), and a per-frame comparison gave up on the first quiet frame, leaving the panel 253px short of where the user had been — which is exactly the "the page scrolls somewhere it shouldn't when I go back" report. The load-bearing half of the fix is that the restore **borrows the height it is missing**: while the view cannot hold the offset, the panel's own `padding-bottom` grows by exactly that difference (a scroll container's end padding is part of its scrollable area), so the frame the swap lands on can carry the offset itself — the browser's clamp before the first paint has nothing to clamp, and the position never moves afterwards. As the rows arrive the borrowed height shrinks frame by frame, and the first frame that can hold the offset on the view's own height returns it to the stylesheet (`borrowHeight` / `repayHeight`). For the ~100-150 ms in between, the screen shows the page's own background where the rows are about to be — the price of not painting a position the panel is about to leave. Measured on the same page (viewport 720px, remembered 1850): before, the swap landed on 1674 and was pushed to 1850 at +114 ms; now the first frame is 1850 and the 224px it borrowed go back on the frame the rows land, with the position untouched. A view genuinely shorter than the remembered offset (a component uninstalled since) keeps the borrowed height its position needs, so the reader stays where they came back to and the page stays that much taller than its content until they leave it. The retry now stops on any of four conditions: the offset is reached, the height has been still for `QUIET_MS` (260 ms), the 900 ms deadline passes, or **the user moved the panel** (after which the position is never written again; the loop only returns the borrowed height, and only when returning it moves nothing). That last one is an exact discrimination rather than a guess, and it again rests on the no-scroll-event fact above: the only events that can arrive while a restore is retrying are the swap's single clamp (carrying the position the restore already holds) and real input, so an event that moves the panel off that position is a hand on the wheel, and the retry stands down rather than dragging anyone back. Measured: the user's scroll to 77 inside the window stays at 77, and a later scroll to 90 stays at 90. **No quiet window is needed around a swap**: the one recording a swap can produce is the clamp, and it always carries the position the incoming view is actually showing — either the offset just restored (`min(memory, max)`, so the browser has nothing left to clamp) or the 0 this tweak has just written for a page being entered or returned to at its top. Every swap therefore ends on a position the tweak chose, and recording it only repeats that choice. The one swap that used to end somewhere else is the memory-of-0 case, which wrote nothing at all and kept the clamp's number: on a harness panel (600 px port; out of a 2200 px page at 1500, back to a list whose content then holds 440) the landing was 440 with zero writes, where the write now puts it at 0. **Scroll anchoring is turned off for this scrollport** (`overflow-anchor: none`, and only this one): anchoring exists to hold a reading position when content resizes above the viewport, but here every navigation replaces the whole content, so the anchor node the browser picked is gone before the adjustment can be honoured and it lands as an arbitrary jump — measured on the same sequence, 1023 with anchoring off and 1316 with it on (right for one frame, dragged away a quarter-second later). What that costs is the panel losing "the viewport stays put when a card's content grows above it", on a panel whose content is replaced wholesale and therefore has no reading position to protect. **The memory lives in module scope** — neither `detach` nor a re-mount of the plugin clears `offsets`. The manager is a global panel that unmounts entirely when you switch away (and the host resets the navigation store to the list on the way out), and any settings save re-mounts every tweak of this plugin (`index.tsx`'s `sameMountInputs` exempts only `closedWorkspaces` and `historyPageSize*`). An instance-scoped map forgot every page but the one on screen at the moment of a save, after which 返回 landed at the top — exactly the bug this tweak exists to fix. Re-opening the panel is entering and restores nothing — but a position recorded before that unmount must survive it, because a return to that page later in the same visit still has to land right. **The pinned header** is one `[data-plugin-panel] [class*="_detailTop"]` rule — the host's own rule is (0,1,0) and sets none of these four properties, so (0,2,0) wins outright with no `!important`: `position: sticky; top: 0` pins the crumb, the icon and the actions to the top of the scrollport (on all three levels), `z-index: 2` lets the bar cover the rows passing beneath it, `padding-bottom: 12px` gives a pinned bar room to breathe, and `margin-bottom: -12px` hands that 12px straight back — a bare padding would add 12px between the icon row and the page's title on every page, resting and stuck alike, whereas the equal negative margin leaves the resting layout byte-identical to stock and puts the 12px inside the bar once it is pinned. The background is `--dsw-alias-bg-base`, and that is not a guess: the panel's scrollport is transparent and the frame behind it paints exactly that token (`ui-layout`'s `.frame { background: var(--dsw-alias-bg-base) }`, and the same token on `.centerCol` under a Windows title bar), verified pixel-identical to the panel in both themes on 0.2.0-rc.2 (light `#fff`, dark `#151517`); content only ever occupies the `.page`'s own centred 960px column (`.page > * { max-width: 960px }`), so the gutters beside it stay empty and covering that column is enough. Lifecycle: the panel mounts and unmounts with the global panel it belongs to, so a body-level MutationObserver (gated by `touchesScope`, or a streaming answer would schedule a frame and a document-wide query per mutation) tracks whichever element currently answers `[data-plugin-panel]`; attaching only *seeds* the view it opened on (never restores, since opening the panel is entering), so a settings change — which re-mounts every tweak — can never yank the view out from under the user. Hosts without the plugin management panel leave the tweak inert.
314
324
  - **Middle-click closes sidebar tabs** (`src/client/tweaks/sidebar-middle-click-close.ts`): a right-Sidebar tab chip is `div[role="tab"][data-dockkit-tab="<tabId>"]`, rendered by `ui-dockkit`'s `TabPanel`, and the docking kit is adopted only by the right Sidebar (docked panel plus floating layer), so "chip" is exactly "Sidebar tab". The tweak hangs three listeners on `document` in the capture phase (React 17+ listens at the root container, so document capture precedes the whole React tree and the interception is total): `pointerdown` (middle) stops propagation so the chip's own press→drag gesture never begins — holding the middle button and drifting ≥4px can no longer place, split, or float the tab; `mousedown` (middle) calls `preventDefault()`, suppressing the browser's middle-click autoscroll (the `auxclick` that follows still fires); `auxclick` (middle — browsers deliver non-primary clicks as `auxclick`, never `click`) reads the chip's `TabId` and hands it to `ctx.sidebarRight.close()`, so every closing rule is the host store's own planner's (missing tab → no-op, the sole docked guide is unclosable, closing the last docked tab collapses the column) and the plugin duplicates none of them. `ctx.sidebarRight` only exists on 0.1.5+ hosts, and it is provided by `ui-sidebar-right` in that package's own plugin fiber — a plain property read resolves services only along the ancestor-fiber chain, so from a sibling plugin it throws ("cannot get property … without inject") even on hosts that have the face, while declaring the service in this plugin's `inject` would make it required and dead-lock the boot on pre-0.1.5 hosts; so the face is resolved at every middle close through `ctx.reflect.get('sidebarRight')` — the reflect instance and its store are shared per root and `provide` allocates the isolation key at the root, so `get` reads sibling-provided services across fibers with no inject requirement, and an unprovided name answers `undefined` instead of throwing (pre-0.1.5 hosts → `undefined`, where the tweak keeps its inert form — side-effect suppression only); a `close()` throw with no mounted seat is swallowed. Floating panels render no chip: their middle close forwards to the header's own ✕ control (absent — a no-op — when the embedder's `canCloseTab` withholds it), while the header's dock button is explicitly excluded; a press and a release on different elements hand the `auxclick` to their common ancestor, which answers neither helper and closes nothing.
315
325
  - **Right sidebar initial width** (`src/client/tweaks/rightbar-initial-width.ts`): the right sidebar's width belongs to the host's `ui-layout` — its `openRightbar` action fills an unset preference with `rightbar ??= max(300, round(viewportWidth × 0.45))` (the `RIGHTBAR_DEFAULT_RATIO` constant), a drag overwrites it through `setRightbar` (clamped to `[300, window × 70%]`), and every later open/close keeps that px; the preference lives only in the in-memory store, so a reload drops it back to `null`. `ctx.layout` exposes only `openRightbar(track, fullscreen)` / `closeRightbar()` / `toggleSidebar()` / `selectPanel()` — the width action lives on the controller's own `panels` set (declared `private` in TypeScript, a plain runtime property). The tweak therefore wraps the controller **instance**'s `openRightbar`: on the FIRST open of a page load it measures the parent of `[data-rightbar-col]` (the very frame `ui-layout` measures for `viewportWidth`), writes `round(width × percent)` through the host's own `panels.setRightbar`, and only then forwards the original call — the write and the open land in the same React commit (never a first frame at 45%); neither the grid template nor the panel's inline width is touched, so the column solve, the drag handle's position and the panel width stay in agreement by construction. Two page-level markers hold the semantics: `__cst_rightbar_initial_width_seeded__` allows one write per load, and `__cst_rightbar_initial_width_dragged__` is set by a **one-shot** probe on the first `pointerdown` over `[data-side="rightbar"]` (the host's right-column drag handle) — once the user has dragged by hand, this tweak writes nothing for the rest of the load, since every settings change re-mounts every tweak and would otherwise overwrite the width just dragged. With the sidebar already open as the tweak mounts (the user editing the percentage in Settings) the new ratio is written immediately — the live preview. It stays inert when the host provides no `layout` service or its controller no longer carries a callable `setRightbar`, and disposal restores the host method only while the installed wrapper is still the current value, so another plugin's wrapper is never clobbered.
316
- - **Legacy stats line** (`src/client/tweaks/legacy-stats-line.tsx`): 0.1.5 replaced the composer stats line (the old `StatsLine`) with icon pills (the new `StatsPills`); both mount the same way — a `list` entry with id `stats` on the `conversation.composer.dock` slot. A list cell renders its lowest-priority live entry (see `SlotCore.register`), so while the tweak is on, the plugin shadows the shipped entry with `id: 'stats'` + `priority: -2` (one step below the pills-decimals row, which it outranks when both are on) — the slot system's own shadowing mechanism, no CSS hiding or DOM patching — and disposing the registration hands the cell straight back; a crashed entry retires itself, restoring the pills. The data plane matches the pills: the durable `sessionStats` (whole-log counts and wall times) and `tokenUsage` (billing buckets) projections, without the old whole-window fallback fold (no projection → no row, matching the old line's "no data, no row" rule). Copy lives in the plugin's own `style-tweaks` namespace (`legacyStats.*` keys) — DSH removed the old `stats.llm` family from its dictionaries in 0.1.5, so the plugin carries its own en/zh strings; the row skin (centered, tertiary text, ellipsis truncation) is ported from 0.1.2's `StatsLine.module.css` with the original tokens plus fallbacks. The row skin adapts to the host's dock generation: 0.1.6-alpha.2 moved the dock slot's output into a centered flex row inside the InputBar (beside the new ContextMeter capsule, the context pill — the slot output is no longer a free row under the input card), and the row root probes its layout parent at mount (walking up past the `display: contents` wrapper the slot machinery inserts — the wrapper generates no box, so its `flexDirection` computes to the initial `row` and a direct read would classify both dock generations as row-flex) — a column flex container (alpha.1 and earlier hosts, where the row hangs directly under the card) takes the old skin: the row root carries the `data-composer-stats` marker, the host InputBar tightens its own 8px bottom clearance to 4px around any mounted stats row (`.root:has([data-composer-stats])`), and the row adds 4px of top + 2px of bottom padding so its total matches the pills row (26px row + 4px host clearance on both presentations); a row flex container (alpha.2's `.dock` wrapper, which owns the 4px top clearance, with a fixed 4px root bottom pad and the `data-composer-stats` rule removed — the marker is inert there) takes the new skin: the row sizes to its content with no vertical padding of its own, centered beside the capsule at the host's native 12px gap — toggling the tweak shifts neither the conversation nor the input box on either generation. The cache-hit share's precision follows the `pillsCacheHitDecimals` flag — two decimals while it is on, 0.1.2's integer rounding while off; the flag is captured at mount, and any settings change remounts every tweak, so the line always renders with the current value.
317
- - **Cache hit with two decimals** (`src/client/tweaks/pills-cache-hit-decimals.tsx`): the pills' percent rounding happens inside a module-internal formatter in dsh-client-ui-chat that a plugin cannot reach, so while the tweak is on, it shadows the same `stats` slot cell (`priority: -1`) with its own render of the whole row — both pills and their click-open dialogs ported style-for-style from 0.1.5's `StatsPills` / `stat-dialog` (placement and outside-close ride the host primitives' own `useAnchoredPosition` / `useDismissOnOutsidePointer`), with the cache hit routed through the plugin's shared `formatCacheHitPercent(…, 2)` (two-decimal rounding; a hit that would read `100%` keeps its honest extra-precision tail, e.g. `99.97`). The dialog rows and pill labels follow. The data plane is the same `sessionStats` / `tokenUsage` projections (no window fold), and the root carries `data-composer-stats` as well (0.1.6-alpha.2 removed the host's bottom-clearance rule behind it; the marker only acts on earlier hosts), with the row skin following the dock generation the same way the legacy line's does (see `composer-dock.ts` and the previous entry). One icon note: the pills' gauge icon only exists in primitives 0.1.3+, so the host's `IconGaugeOutline16` is looked up at runtime and a clock icon stands in on hosts that predate it. Layering with the legacy line: both tweaks write the same slot cell; the legacy line registers at `priority: -2` and renders instead, leaving this row shadowed (unrendered, zero cost) until the legacy line is turned off, and the shipped pills return only when both tweaks are off. The toggle stays visible either way: the legacy line reads the same flag for its own decimals.
318
- - **Turn time and speed** (`src/client/tweaks/turn-time-pill.tsx` + `src/client/tweaks/assistant-stream-timing.ts`): 0.1.6's `TurnUsagePanel.tsx` held two components side by side — `TurnUsagePanel` (data glyph + usage) and `TurnTimePanel` (clock glyph + time, opening the 本轮用时和速度 dialog) — and `TurnTailNodeView` passed both to the actions row as one `usageAction` fragment; 0.1.7-alpha.1's performance-and-usage preference deleted `TurnTimePanel`, `TurnTailChatData`'s `ttftMs`/`tokensPerSecond`, `formatLatencySeconds`, and `message.ranFor` with the whole `message.turnTime.*` group. This tweak puts the time half back whole: the pill, the dialog, and 0.1.6's three rows inside it. Placement: it takes the only route a plugin has into host chrome — an invisible anchor on the `conversation.chat.assistant-actions` list slot (whose list renders between the copy and branch controls), from which the visible pill is portaled into the tail's own trailing cluster, the span wrapping `usageAction` and the clock (found structurally as the last element child of the row carrying `data-clock="end"`). The portal lands last in that cluster, so two CSS rules restore 0.1.6's seating: the clock (the cluster's `_timeEnd` span, the portal's previous sibling in the DOM) is ordered after the pill, and the 8px flex gap against the usage pill is rebated by 6px — 0.1.6's own `.root + .root` pairing — so the row reads usage → time → clock again, exactly the cluster 0.1.6 printed; CSS `order` rather than DOM insertion keeps that placement stable no matter when React inserts the host's own children (a cluster without the usage pill keeps the plain 8px rhythm, one without a clock ends with the pill). Figures: 0.1.6 read them off the Chat snapshot's turn location (wall time) and the node's in-memory timing, neither of which survives in 0.1.7's node data, so all three are folded out of the session event window instead — the wall time over `turn/start` → `turn/end` (`deriveTurnRunMs`), the output speed and TTFT over the settlements' embedded model streams (`deriveTurnSpeedMetrics`). The latter is exactly the consumer the session-format-v2 (0.1.5) architecture note anticipates: v2 embeds each model attempt's exact timed stream inside its durable settlement (`assistant/message`'s `data.stream`), but the Chat UI's cold presentation builds settled output straight from the assembled message and never replays it (the note, verbatim: "Cold settled presentation therefore does not reconstruct per-token timing") — the turn footer's own fold reads the node's in-memory timing, whose `firstTokenTime` only survives live-chunk folding, so every turn's output speed (TPS) and first-token time (TTFT) vanish across a reload and the "turn time & speed" dialog keeps only the wall-clock duration. The compact-stream readers are ported verbatim from dsh-llm (`assistantStreamFirstTokenTime`: reconstructs the first token's time inside a packed run from `time0` plus the `dt` gap list) together with 0.1.5's `deriveTurnMetrics` fold — TTFT is the lowest step's dispatch→first-token delta, TPS is Σ output tokens ÷ Σ decode wall time over the steps carrying both timing and usage — and everything rides the durable log, so history loads and freshly settled turns show identical figures. Timing: the tail only exists once the turn settled, so the values are final; while `turn/end` has not reached the window (or was evicted by its cap) the tweak watches the `eventSource` subscription until the wall time appears and then stops (settlements always precede `turn/end`, so both derived figures are ready at that moment), and a turn with no `turn/start` in the window renders no pill, leaving the 0.1.7 row untouched. Dialog: a port of the host's stat-dialog seat over the same public primitives the host itself uses (`useAnchoredPosition` for the viewport-clamped placement above the trigger, `useDismissOnOutsidePointer` for outside dismissal) plus an Escape handler, with the surface skin copied from `stat-dialog.module.css`; its details grid carries the host's `data-turn-time-details` marker and keeps 0.1.6's `TurnTimePanel` row order and "a row only when its figure is known" rule (total run time → output speed → TTFT). The two figure labels come from this plugin's own `style-tweaks` dictionary (the host's surviving `stats.dialog.speed` / `stats.dialog.ttft` pair is worded for the composer's whole-session dialog, where `ttft` reads "首 token 平均", an average that does not hold for one turn), while the values keep the host vocabulary's `message.tokensPerSecond` / `duration.seconds` keys. All three rows render in the mount commit, so nothing is ever appended to the panel after it is measured and the first painted frame is already final (the earlier "render one row, inject two on open" approach needed a synchronised card lift to avoid a one-frame flash; rendering the rows up front replaced it). Selector safety: every hook is structural (`data-turn-tail` / `data-clock="end"` / the `_timeEnd` class suffix inside the cluster / `data-turn-time-details`), the pill's own classes are namespaced `cst-ttp-`, and a host that renames `timeEnd` degrades to the pill simply ending the row.
326
+ - **Custom sidebar session count** (`src/client/tweaks/sidebar-session-count.ts`): the host folds each Workspace's session list to five rows (`COLLAPSED_SESSION_LIMIT`), and that limit is a `useState` inside its browser component — the persisted view store keeps only `groupBy / orderBy / groupExpansion / sessionOrderByAccount / archivedFilter`, so no setting, slot or store action reaches it from outside the host. The tweak therefore **drives the control the host already renders** instead of replacing it. Its quota exempts exactly one row: **a provisional New Session row — one that has not sent its first message yet — never spends a configured row**. That flag is read from the session store (`ctx.get('sessions').list`, the `SessionSummary.blank` flag) and matched to its row by `data-row-key="session:<id>"`; the exempt row is always visible and free, and both halves of the published figure (the total the remainder is measured against, and what is shown) count only rows that spend a slot. **A running session still counts** — continuing a history session is exactly that case — which is a deliberate departure from the host: the host's own `collapsedSessionRows` exempts `running` and `runningSubagentCount > 0` as well, while this tweak lets only the blank row through. The remainder figure is unaffected: the host's own label counts the idle sessions it has not rendered, and a blank row is never one of them (the host renders it unconditionally), so "not rendered + rendered slotted rows" is the slotted total exactly. The host's overflow button carries `data-row-key="overflow:<groupKey>"` and `aria-expanded` (true once nothing is hidden), and pressing it is the only way to make the host render more rows, so the tweak presses it on the user's behalf and then decides what stays on screen: **grow** (press until at least the configured number of slotted rows — the rows that spend a slot — is rendered; the host advances five at a time and finally jumps to "all" — `Infinity` — when the remainder is five or fewer, so the loop terminates on either the row count or the button going away), **trim** (hide the rows past the configured count — the rows carry no inline style of their own, verified on 0.1.7-rc.2, so one marker attribute `data-cst-sid-hidden` plus a `display: none !important` rule is enough, and a host re-render that keeps the same elements leaves the markers alone), and **re-label** (hide the host's button wholesale — at the style layer rather than from script, since the host inserts the button in the same commit as the rows and an observer only reaches it two microtasks later, which would leave its recomputed-on-every-render count on screen meanwhile — and put the plugin's own `data-cst-sid-button` in its place, carrying the host's own `workspace` wording (`sessions.expand` / `sessions.collapse`) and the honest remainder as the figure: the "N more" in the host's label plus the rows this trim is holding, i.e. `total − shown`). Pressing the plugin's button changes only the trim count; growing is always delegated back to the host, so the host's own "show everything" state backs a long list, while collapsing trims back to the initial count rather than asking the host to fold to five (the host's own fold cannot express a custom count). **The grow is never hidden**: an earlier revision marked the list `visibility: hidden` during a grow to conceal the 5 → 10 → 15 ladder, but that window is one frame (a press is a single render, well under 100ms) and a hide that short still reads as a flicker — the list vanished and came back — while the host's own row glide already presents the steps as one growth, so growth is now always shown. **The button is published on the grow's first landing, not before it**: the figure never needed the wait — the total is the host's own "N more" label plus the rendered rows that spend a slot, and `shown` is the trim the pass is about to apply — but `shown` is also the block the pass will END at, and that size can still move while the grow runs (unfolding a Workspace renders the rows before the app marks its selected row), so publishing up front promised seven rows and delivered fourteen (reported 2026-09-28 as "the number changes again"). The publish now fires on the first LANDING, a frame or two later, and the total is held for the rest of the pass so the number lands once and stays put (measured: the button used to appear at t=199ms with all eleven rows already on screen by ~50ms); the one case that cannot publish then is a host label with no integer in it (everything already shown, or not painted yet), which is read the settled way. **The cached total is only trusted while the host is still folding**: once the host stops rendering its own overflow button, that is proof it is showing every session it has, so the cache is dropped — otherwise archiving or deleting sessions left the button advertising a remainder that no longer existed (measured shape: nine sessions at a configured seven, six archived, and the button kept reading "6 more" while pressing it only rewrote the same number). **A shrink has a second shape that must drop the cache too** (review fix, 2026-10-06): once the host's limit is `Infinity` its overflow button is STILL rendered (its condition reads the host's own five-row fold, not the limit) while carrying no integer, so the "no button" test never fires — and archiving or deleting sessions then leaves rendered rows below the count the cache was measured on. The cache therefore carries a high-water mark of the slotted rows it has been checked against (`data-cst-sid-totalrows`), raised whenever the list is seen to grow past it: below that mark the cache is dropped and the answer falls back to "what is on screen is everything". (Recording the count only where the total is written would not do — that happens while the list is still folded, so the later, longer list sits above it and a shrink still reads as "it only grew, the cache is fine".) Measured shape: seven sessions at a configured six (the host folds to five with two behind the fold), expanded to "all", one archived — six rows on screen while the cache still claimed seven, so the button offered a session that was not there and did nothing when pressed. **Figure and press read one value**: `publish` decides "there is a remainder" and the press decides "grow or fold" from the same `shown`, so a button can never promise an expansion and then fold the list. **The wait machinery**: the host's row glide runs ~200ms (`ROW_GLIDE_MS`) and a departing row stays in the DOM for that window, where it still matches the row selector, so a corrective pass follows after `GLIDE_SETTLE_MS` (300ms); waits prefer rAF and fall back to a timer (an occluded window suspends rAF), each settle carries a wall-clock ceiling so a suspended frame cannot freeze a group behind its busy mark, and a press landing while a pass holds the group is queued and served by that pass's release rather than dropped. **A selected row is never hidden, and never cut off**: opening a session — including one found through the host's own search, which reveals the row and scrolls to it, or one the locate button found — marks it `aria-selected="true"` and the trim then takes only the rows BELOW it: everything above stays visible, so the hidden marks are always a SUFFIX of the rendered list and the row is never drawn under the fold (the visible block becomes **the next configured size above that row** — `initial + a whole number of steps`, so with 7/7 the ninth session gives 14 rows and the fifteenth 21 — and the plugin's own button grows from the group's current block rather than from the configured initial value; that raise is **remembered on the group**, so clicking an earlier session keeps the block open instead of hiding the rows the user was just looking at; folding the Workspace resets the part that "show more" built (a folded list comes back compact) but keeps the size the SELECTED row requires — a reopened group renders only the host's own five rows, so a requirement dropped there could never be measured again; a settings change RE-MOUNTS the tweak but is not a fold, so those latches (`trim` / pressed mark / cached total / selection requirement) stay on the groups and saving a setting never collapses an expanded list — the new counts reach the lists that carry no latch, and only switching the feature off forgets them, through `resetSidebarSessionCountLayout()` in the mount loop, which is also what a plugin teardown runs); **a row the trim reveals eases back in** over 160ms on `opacity` and `height`, so the list grows into its new shape instead of snapping — the hide direction stays instant, since a row past the fold must never be presented at all; neither the host's own row animations nor the browser's smooth scrolling run under `prefers-reduced-motion: reduce` (this machine reports it), which is why both motions are driven by the plugin — and both are governed by the same section's "Expansion animation" switch (**default off**, i.e. a single frame; on, the reveal eases over 160ms and the locate scroll over 280ms, and with the switch off that scroll runs synchronously instead of waiting a frame; both motions also require the section's master switch, so with the custom row count off the sidebar and the locate scroll are DSH's own); when the host itself lifts a group's limit to reveal a row, that row renders first and the next tick raises the visible block up to it. **Steady state costs nothing**: a per-group fingerprint of everything a pass reads or writes lets a sweep skip groups whose settled state has not moved, so a sidebar whose rows keep updating for other reasons no longer buys every open group a pass per mutation. Every anchor matches on the stable semantic fragment (`groupSection` / `sessionOverflowButton` / `list` are CSS-Modules hashes that rotate per build), and `data-row-key` is the host's own contract carrying the group key — the key every per-group state is held by. **The `workspace-tree` grouping mode is isolated by ownership**: that mode nests a child Workspace's `groupSection` inside its parent's (the host's `renderGroup` order is `[workspace row, div[role=group]{children}, the parent's own rows, its overflow button]`), so rows, the host's overflow button, the selected row and the plugin's own button are all filtered by `closest(groupSection) === this group`. Without that filter a parent's trim counted its children's rows as its own budget and marked the parent's real sessions hidden, and no pass would ever un-hide them — trimming only ever runs for the innermost group. **The plugin's own button is claimed by an instance token**: a button carries the token of the mount that wired it and is replaced rather than adopted when the token does not match, so a button left behind by a grow that outlived its mount (any settings save re-mounts every tweak) cannot be mistaken for "already wired" and go permanently inert; the disposer removes only its own token's buttons.
327
+
328
+ - **Legacy stats line** (`src/client/tweaks/legacy-stats-line.tsx`): 0.1.5 replaced the composer stats line (the old `StatsLine`) with icon pills (the new `StatsPills`); both mount the same way — as `list` entries on the `conversation.composer.dock` slot. A list cell renders its lowest-priority live entry (see `SlotCore.register`), so while the tweak is on, the plugin shadows the shipped entries with `priority: -2` (one step below the pills-decimals registrations, which it outranks when both are on) — the slot system's own shadowing mechanism, no CSS hiding or DOM patching — and disposing the registration hands the cells straight back; a crashed entry retires itself, restoring the pills. Which cells exist follows the host generation: through 0.2.0 one entry (`stats`) carried both pills, and 0.2.1-alpha.1 split it into one entry per pill — `activity` (order 0, counts and speed) and `usage` (order 1, token total and cache hit). `composerStatsCells` (`composer-dock.ts`) reads their ids and orders off the slot ledger: the shipped entries are the priority-0 occupants (a second registration at the same id and priority throws, so that seat can only be the host's, and this plugin's shadows all sit lower). The line claims the first cell and suppresses any further one with a renderer that returns `null` (the line already carries the token figures those pills would repeat), and every claim re-registers at the shipped entry's own `order`, so the dock's arrangement is untouched. The data plane matches the pills: the durable `sessionStats` (whole-log counts and wall times) and `tokenUsage` (billing buckets) projections, without the old whole-window fallback fold (no projection → no row, matching the old line's "no data, no row" rule). Copy lives in the plugin's own `style-tweaks` namespace (`legacyStats.*` keys) — DSH removed the old `stats.llm` family from its dictionaries in 0.1.5, so the plugin carries its own en/zh strings; the row skin (centered, tertiary text, ellipsis truncation) is ported from 0.1.2's `StatsLine.module.css` with the original tokens plus fallbacks. The row skin adapts to the host's dock generation: 0.1.6-alpha.2 moved the dock slot's output into a centered flex row inside the InputBar (beside the new ContextMeter capsule, the context pill — the slot output is no longer a free row under the input card), and the row root probes its layout parent at mount (walking up past the `display: contents` wrapper the slot machinery inserts — the wrapper generates no box, so its `flexDirection` computes to the initial `row` and a direct read would classify both dock generations as row-flex) — a column flex container (alpha.1 and earlier hosts, where the row hangs directly under the card) takes the old skin: the row root carries the `data-composer-stats` marker, the host InputBar tightens its own 8px bottom clearance to 4px around any mounted stats row (`.root:has([data-composer-stats])`), and the row adds 4px of top + 2px of bottom padding so its total matches the pills row (26px row + 4px host clearance on both presentations); a row flex container (alpha.2's `.dock` wrapper, which owns the 4px top clearance, with a fixed 4px root bottom pad and the `data-composer-stats` rule removed — the marker is inert there) takes the new skin: the row sizes to its content with no vertical padding of its own, centered beside the capsule at the host's native 12px gap — toggling the tweak shifts neither the conversation nor the input box on either generation. The cache-hit share's precision follows the `pillsCacheHitDecimals` flag — two decimals while it is on, 0.1.2's integer rounding while off; the flag is captured at mount, and any settings change remounts every tweak, so the line always renders with the current value.
329
+ - **Cache hit with two decimals** (`src/client/tweaks/pills-cache-hit-decimals.tsx`): the pills' percent rounding happens inside a module-internal formatter in dsh-client-ui-chat that a plugin cannot reach, so while the tweak is on, it shadows the cell that carries the figure (`priority: -1`) with its own render — the pill and its click-open dialog ported style-for-style from 0.1.5's `StatsPills` / `stat-dialog` (placement and outside-close ride the host primitives' own `useAnchoredPosition` / `useDismissOnOutsidePointer`, plus 0.2.1's close-on-outside-CLICK: a keyboard activation of the sibling pill fires `click` without `pointerdown`, and without that listener the two dialogs would stack), with the cache hit routed through the plugin's shared `formatCacheHitPercent(…, 2)` (two-decimal rounding; a hit that would read `100%` keeps its honest extra-precision tail, e.g. `99.97`). The dialog rows and pill labels follow. Which cell it claims follows the host generation: through 0.2.0 the single `stats` cell holds both pills and the tweak re-renders the whole row; from 0.2.1-alpha.1 each pill is its own entry and only the `usage` one carries the cache hit, so the tweak claims just that cell (same id, same `order`, `priority: -1`) and leaves the shipped `activity` pill alone — the smallest shadow that does the job, with no second copy of the host's counts/speed logic to keep in step. The replacement root carries `data-composer-stat="usage"` (0.2.1's per-pill hook, on the shipped pills too) and the shipped entry root's 12/20 text tier, so it sits flush with the native `activity` pill beside it. The data plane is the same `sessionStats` / `tokenUsage` projections (no window fold); in row mode (≤0.2.0) the root carries `data-composer-stats` as well (0.1.6-alpha.2 removed the host's bottom-clearance rule behind it; the marker only acts on earlier hosts), with the row skin following the dock generation the same way the legacy line's does (see `composer-dock.ts` and the previous entry). One icon note: the gauge icon of the row mode's own pills only exists in primitives 0.1.3+, so the host's `IconGaugeOutline16` is looked up at runtime and a clock icon stands in on hosts that predate it (on 0.2.1+ the gauge pill is the host's own and untouched). Layering with the legacy line: both tweaks write the same cells; the legacy line registers at `priority: -2` and renders instead (claiming the first cell and suppressing the rest with a null renderer), leaving this tweak's cell shadowed (unrendered, zero cost) until the legacy line is turned off, and the shipped pills return only when both tweaks are off. The toggle stays visible either way: the legacy line reads the same flag for its own decimals.
330
+ - **Turn time and speed** (`src/client/tweaks/turn-time-pill.tsx` + `src/client/tweaks/assistant-stream-timing.ts`): 0.1.6's `TurnUsagePanel.tsx` held two components side by side — `TurnUsagePanel` (data glyph + usage) and `TurnTimePanel` (clock glyph + time, opening the 本轮用时和速度 dialog) — and `TurnTailNodeView` passed both to the actions row as one `usageAction` fragment; 0.1.7-alpha.1's performance-and-usage preference deleted `TurnTimePanel`, `TurnTailChatData`'s `ttftMs`/`tokensPerSecond`, `formatLatencySeconds`, and `message.ranFor` with the whole `message.turnTime.*` group. This tweak puts the time half back whole: the pill, the dialog, and 0.1.6's three rows inside it. Placement: it takes the only route a plugin has into host chrome — an invisible anchor on the `conversation.chat.assistant-actions` list slot (whose list renders between the copy and branch controls), from which the visible pill is portaled into the tail's own trailing cluster, the span wrapping `usageAction` and the clock (found structurally as the last element child of the row carrying `data-clock="end"`). The portal lands last in that cluster, so two CSS rules restore 0.1.6's seating: the clock (the cluster's `_timeEnd` span, the portal's previous sibling in the DOM) is ordered after the pill, and the 8px flex gap against the usage pill is rebated by 6px — 0.1.6's own `.root + .root` pairing — so the row reads usage → time → clock again, exactly the cluster 0.1.6 printed; CSS `order` rather than DOM insertion keeps that placement stable no matter when React inserts the host's own children (a cluster without the usage pill keeps the plain 8px rhythm, one without a clock ends with the pill). Figures: 0.1.6 read them off the Chat snapshot's turn location (wall time) and the node's in-memory timing, neither of which survives in 0.1.7's node data, so all three are folded out of the session event window instead — the wall time over `turn/start` → `turn/end` (`deriveTurnRunMs`), the output speed and TTFT over the settlements' embedded model streams (`deriveTurnSpeedMetrics`). The latter is exactly the consumer the session-format-v2 (0.1.5) architecture note anticipates: v2 embeds each model attempt's exact timed stream inside its durable settlement (`assistant/message`'s `data.stream`), but the Chat UI's cold presentation builds settled output straight from the assembled message and never replays it (the note, verbatim: "Cold settled presentation therefore does not reconstruct per-token timing") — the turn footer's own fold reads the node's in-memory timing, whose `firstTokenTime` only survives live-chunk folding, so every turn's output speed (TPS) and first-token time (TTFT) vanish across a reload and the "turn time & speed" dialog keeps only the wall-clock duration. The compact-stream readers are ported verbatim from dsh-llm (`assistantStreamFirstTokenTime`: reconstructs the first token's time inside a packed run from `time0` plus the `dt` gap list) together with 0.1.5's `deriveTurnMetrics` fold — TTFT is the lowest step's dispatch→first-token delta, TPS is Σ output tokens ÷ Σ decode wall time over the steps carrying both timing and usage — and everything rides the durable log, so history loads and freshly settled turns show identical figures. Timing: the tail only exists once the turn settled, so the values are final; while `turn/end` has not reached the window (or was evicted by its cap) the tweak watches the `eventSource` subscription until the wall time appears and then stops (settlements always precede `turn/end`, so both derived figures are ready at that moment), and a turn with no `turn/start` in the window renders no pill, leaving the 0.1.7 row untouched. Dialog: a port of the host's stat-dialog seat over the same public primitives the host itself uses (`useAnchoredPosition` for the viewport-clamped placement above the trigger, `useDismissOnOutsidePointer` for outside dismissal) plus an Escape handler, with the surface skin copied from `stat-dialog.module.css`; its details grid carries the host's `data-turn-time-details` marker and keeps 0.1.6's `TurnTimePanel` row order and "a row only when its figure is known" rule (total run time → output speed → TTFT). The two figure labels come from this plugin's own `style-tweaks` dictionary (the host's surviving `stats.dialog.speed` / `stats.dialog.ttft` pair is worded for the composer's whole-session dialog, where `ttft` reads "首 token 平均", an average that does not hold for one turn), while only the speed value keeps the host vocabulary's surviving `message.tokensPerSecond` key — both durations, like the labels, come from this plugin's own vocabulary (`durationSeconds` / `durationMinutes` / `durationHours`), because 0.2.0-rc.2 replaced the host's three whole-string duration templates with bare units plus `compact*` and a call for one of the old keys prints the key name. All three rows render in the mount commit, so nothing is ever appended to the panel after it is measured and the first painted frame is already final (the earlier "render one row, inject two on open" approach needed a synchronised card lift to avoid a one-frame flash; rendering the rows up front replaced it). Selector safety: every hook is structural (`data-turn-tail` / `data-clock="end"` / the `_timeEnd` class suffix inside the cluster / `data-turn-time-details`), the pill's own classes are namespaced `cst-ttp-`, and a host that renames `timeEnd` degrades to the pill simply ending the row.
319
331
  - **Call counts on process groups** (`src/client/tweaks/turn-process-counts.ts`): 0.1.6's `TurnProcessNodeView` built its header label out of `node.data.toolCallCount` / `messageCount` / `subagentCount`, joining the non-zero parts with `message.turnProcess.separator` (falling back to "已思考" when all three were zero); 0.1.7-alpha.1's performance-and-usage preference replaced that label with the elapsed-time wording (`message.turnProcess.took` / `deepDivingFor` / `failed` / `stopped` / `worked`). The counts themselves did not go away — the node data still carries all three, the host still writes them onto the disclosure button as `data-turn-process-tool-calls` / `-messages` / `-subagents` (`button[data-turn-process]`), and the whole `message.turnProcess.*` tally vocabulary is still in the shipped `chat` dictionary. The process group is a transcript node view, not a slot, so a plugin has no render seat inside it and the tweak patches the DOM instead: it appends one `span.cst-tp-counts` child to the host's button and never touches the host's own children. The text is rebuilt by 0.1.6's own rule — a zero (or missing) count is left out, a single count takes the `one` key, the parts are joined with the host's own separator, and the separator leads the string so the tally reads as a continuation of the host's label (which also keeps it off the truncated tail of a narrow label, since the label is the part that ellipsizes). Order is fixed in CSS rather than by insertion position: the tally must read after the host's label and before the chevron, and the chevron is conditional (`canCollapse`) — when React adds it later it appends to the end of the button, which would land it after the tally. So the tally carries `order:1` and the chevron `order:2` on the button's own flex row (the host's `.root` is `display:flex`), and the reading order holds however React re-inserts its children; the tally also takes the host's font-size / line-height tokens and `flex:none`, so the label still shrinks and ellipsizes exactly as before. Syncing rides one MutationObserver on `document.body` whose `attributeFilter` names only the three count attributes (`data-open` / `aria-expanded` / class churn never schedules a sweep) and whose childList half walks only added subtrees for `button[data-turn-process]`, so a sweep costs what the batch touched rather than what the document holds, with the batch coalesced into one pass per microtask. A host that renders process groups some other way (no such attributes) leaves the tweak inert.
320
- - **Blue running status at the tail** (`src/client/tweaks/running-status.ts`): through 0.1.6, `ChatView` rendered a dedicated `TurnStatus` after the node list. While `running`, it stayed at the live transcript tail—immediately above the composer—as a 26 px one-line brand-blue shimmer, with an ordinary caption-coloured clock added after fifteen seconds. 0.1.7's grouped-work-details refactor removed that React component. `TurnProcessNodeView` still emits a visually-hidden `role="status"` announcement («深度求索中 / Deep diving») before its process button, and the visible header still carries the live clock, but an open process group is forced open: its header stays above the entire reasoning/tool run, so accumulating calls push it away from the composer and the old blue line appears to vanish. This tweak neither moves a host node nor edits the process group. It recognizes an open turn when the direct accessibility announcement beside the current `button[data-turn-process]` is a **non-settled state**, then appends one `div.cst-running-status` to that control's `[data-chat-flow]`. It uses the announcement text as-is rather than forcing a fixed “Deep diving” label, so a companion plugin that changes it to “Deep sleep” / “Deep Sleeping” is followed too. The visible row is a visual clone of the host's current label and is `aria-hidden`; the host announcement remains the sole live region, so one-second clock ticks are not read aloud. Duration extraction is not a zh/en string table: a private marker is substituted into the same `message.turnProcess.deepDivingFor` template the host used to build the label, and the text between that template's literal prefix and suffix is read back, preserving placeholder order in any locale. If a companion plugin changes the wording or template, the complete customized label is kept as one blue line. The row also takes the flex column's last `order`, and is moved back to the DOM tail if React later appends an admitted local echo. Its shimmer, typography, clock placement, and static `prefers-reduced-motion` branch are ported item-for-item from 0.1.6. Hosts through 0.1.6 already render their own `TurnStatus` and do not put the 0.1.7 announcement beside a process button, so the selector stays inert; the same is true if a future host changes either structural anchor.
332
+ - **Elapsed time above the process rows** (`src/client/tweaks/legacy-running-header.ts` + `turn-count-stream.ts` + `turn-tally.ts`): through 0.1.7-rc.2 `TurnProcessNodeView` drew a turn's process group immediately, and its disclosure button was disabled — `canCollapse = foldable && hasContent && !turnProcessAlwaysOpen(node)`, and `turnProcessAlwaysOpen` is true for every `open` turn — so the row read as a heading rather than a control. 0.2.0-rc.1 changed two things at once: `TurnProcessNodeView` returns `null` until the turn is closed (so the running label had nowhere to be drawn), and the running label moved to the blue `RunningStatus` row at the live tail. This puts the heading back from two anchors that are both the plugin's own. **Position**: the flow item the host mounts for a running turn and leaves empty, because the node view returned `null` — `div[data-chat-flow-kind="turn-process"]` — whose following siblings are that turn's tool rows and reasoning, so the empty shell sits exactly where the 0.1.7 header used to sit; the row is injected into the `display: contents` slot wrapper inside it, so the host still owns the surrounding layout and the plugin adds one node. **Figures**: `turn-count-stream.ts` folds the same Session events the host folds and publishes the three tallies plus that turn's `turn/start` time, so the elapsed clock is computed from an honest origin rather than scraped off the host's shimmering label once a second; `turn-tally.ts` gives both tweaks one tally vocabulary and one set of duration templates (0.2.0-rc.2 replaced the whole-string templates, so asking the bound `chat` seat for them prints the key names — and TypeScript cannot catch it, because `TranslateNS` is out of step with the runtime dictionary). Three traps of the event contract are written out at the top of the file: `registerDefinition` throws on a duplicate `kind` (hence a `globalThis` counter), the engine hands `start`'s already-folded evidence to `update` as well and the counts double (hence a `foldedThrough` seq watermark in the state), and a turn whose `turn/start` falls outside the loaded window counts nothing at all (hence every matched event claims `role: 'start'`). Registration goes through a local `ctx.inject(['uiConversation'], cb)` rather than the plugin's own `inject` export — the latter would keep the whole plugin from ever activating on an older host, while cordis refuses to read a service the fiber never declared. **It touches nothing of the host's**: the blue tail row is never hidden or restyled, and this tweak never writes an attribute on it; the heading's class list is only its own, and deliberately not `cst-tp-counts` (every rule styling that class requires it to be a direct child of a disclosure button or of the tail row, so the name would match nothing while making the heading look like another tweak's tally to a future document-wide query). The heading is a `div` rather than the host's `button`: no `aria-expanded`, no chevron, nothing on click — a heading that lied about being collapsible would be worse than the 0.2.0 presentation beside it. **It arrives with the turn's first row**, measured by geometry rather than by a marker class, because a turn's first seconds are empty flow items the host itself keeps at `height: 0` and they have no height while the first tool call is still in flight. The walk skips two things, both load-bearing: the tail row itself (`RunningStatus` is a child of the flow column, so a naive "first visible sibling" walk runs into it and reads zero output as output, floating the heading over a group that is still empty — the first run shipped exactly that bug; leave the tail row VISIBLE when exercising this, or the assertion cannot fail), and anything not bounded by `data-chat-turn` (`ChatNodeList` appends queued-message and steering bubbles to the end of the column; they are not flow items and carry no such attribute, yet they have height).
321
333
  - **Opaque stat dialogs** (`src/client/tweaks/opaque-stat-dialogs.ts`): 0.1.7-alpha.1's "Compact translucent menu surfaces" decision (harness note, 2026-09-17) swapped the menu / card material from opaque to translucent-plus-blur — `--dsw-specific-menu` went from 0.1.6's `var(--dsw-alias-bg-layer-3)` to `rgba(248, 249, 250, 0.58)` light / `rgba(48, 49, 54, 0.5)` dark, with a new `--dsw-menu-backdrop-filter: blur(40px) saturate(150%)` that every surface painting that fill must pair with (the host's own stylesheet gate rejects a material layer that omits the filter). This tweak restores the opaque fill on **only** the five click-open stat dialogs (ui-chat's README calls the composer pair 统计交互卡片 / "interactive statistic dialogs", all of them rendering through the host's one `stat-dialog` seat plus the ContextMeter panel): the composer's session-statistics and token-usage cards, the turn-tail per-turn usage and time pills, and the context-occupancy panel. **Why not the token**: `--dsw-specific-menu` *is* the whole elevated-surface family's single definition point, so re-pointing it repaints every menu and panel in the client — far wider than these five dialogs (that was this tweak's first cut, dropped when the scope narrowed). **How the five are targeted**: three of them (the turn-tail usage dialog and both composer stat dialogs) are the host's one `stat-dialog` panel (`ui-chat/src/client/chat/stat-dialog.module.css`, whose direct children are `.title` / `.titleRule` / `.details`), one is the ContextMeter panel (direct children `.header` / `.bar` / `.rows`), and two are this plugin's own ported panels (`.cst-ttp-panel` / `.cst-pilldlg-panel` — plain namespaced classes, nothing to guess). The host marks neither panel kind with any `data-*` attribute, and their class shape — `*_panel` with `role="dialog"` — is shared by a set of UNRELATED dialogs (the settings panel's `SettingsRoot.panel`, the document preview's `FontNotice.panel`, the agent team's `TeamAction.panel`, the memory-save modal), so the two are separated by their own direct children instead: `:has(> [class$="_details"])` and `:has(> [class$="_rows"])`, the same structural-hook discipline as `[class$="_timeEnd"]` / `[data-turn-tail]` elsewhere in this plugin — and that is what keeps the settings panel and the rest out (measured: with the settings dialog open, the selector matches nothing). **Specificity**: the host's rule is `.panel` (0,1,0) and the plugin's own two ports are also (0,1,0), so each rule here carries a `body` element prefix — one extra element selector settles both ties, with no `!important` and no dependence on which of the plugin's stylesheets is injected last. **The `backdrop-filter` is deliberately left alone**: under an opaque fill the blur is fully covered and therefore invisible, while keeping the property preserves the host's own isolation / containing-block semantics (a few of those surfaces paint their material on an isolated background pseudo-element or rely on the filter giving their fixed overlays a containing block). The dark theme needs no separate value because layer-3 is the theme's own dark surface color, and hosts before 0.1.7-alpha.1 already paint the opaque value, making this a same-value no-op there. Side fix: two of this plugin's own ported panels (`pills-cache-hit-decimals`' dialog, `legacy-context-meter`'s panel) had missed the `backdrop-filter` line 0.1.7 added (the `turn-time-pill` port already carried it) — that is now added, so with this switch off the plugin's dialogs match the host's material exactly.
322
334
  - **No context meter hover info** (`src/client/tweaks/context-pill-no-tooltip.ts`): the 0.1.6-alpha.2 ContextMeter capsule is wrapped in ui-primitives' `Tooltip`, whose bubble (`span[role="tooltip"]`) is the **adjacent sibling** of the `cloneElement`'d anchor — so one plain CSS rule hides it exactly: `button:has(> svg[viewBox="0 0 14 14"] > circle) + span[role="tooltip"] { display: none }`. The anchor's structural identification is unique across the client UI: every circle-bearing icon in the icon set (gauge, database, …) is a 16×16 viewBox, and the only 14×14 ring with `circle` children is the ContextMeter's gauge (TodoPanel's 14×14 glyphs are path/rect, circle-free), while the `+` adjacent-sibling combinator binds the bubble to its own anchor only — every other Tooltip in the composer (the + attach circle, etc.) and the plugin rows' own hover tooltips carry no such svg and are untouched. The rule covers both the hover and the keyboard-focus trigger (the same bubble element; the click-open breakdown dialog remains the entry to the full figures); the bubble is driven by React state and the hiding happens at the style layer after it renders, so nothing of the host's behaviour changes and no JS listeners are involved. Hosts up to 0.1.6-alpha.1 ship no ContextMeter, the selector matches nothing, and the tweak is inert.
323
335
  - **Context ring in the input card** (`src/client/tweaks/legacy-context-meter.tsx`): 0.1.6-alpha.2 moved the ContextMeter out of the input card's toolbar row into a new `.dock` wrapper below the card (a ring-and-percent capsule next to the stats row). This tweak restores the pre-alpha.2 presentation: a 28px ring-only button back inside the toolbar row, left of the send button. It deliberately avoids a DOM move — the capsule is a React-owned node, and when React commits an insertion it finds its `insertBefore` reference by walking the fiber tree (`getHostSibling`), not the DOM, without verifying that the reference is still a child of the container — so once the capsule were moved out, a stats row mounting at that moment would throw NotFoundError inside the commit and take the whole app down. Instead it works the way "cache hit with two decimals" does: it registers a cell on the `conversation.input.right` list slot (the toolbar's right group, inside `.trailing` in normal flex flow) with a style-for-style port of the alpha.1 ContextMeter — 28px grid ring, hover reading, the click-open 264px occupancy breakdown, outside-click + Escape close, and the `contextOccupancy` fold ported verbatim — riding the same `contextPressure` / `contextBreakdown` projections, reusing the host primitives' own `Tooltip` for the hover reading, and keeping alpha.1's absolutely-positioned panel skin (anchored above the ring, right-aligned). Host generation is probed by the cell itself (layout effect, settled before the first paint): from its own position it walks past the `display: contents` anchor to its composer card and checks the InputBar root's own children for a `div` whose hashed class carries the `_dock` local name — present means the alpha.2+ generation and the ported ring renders; absent (0.1.6-alpha.1 and earlier) the cell renders only a hidden (`display: none`) ref-holder (the probe needs a mount point; the hidden shell takes no space and adds no row gap), the native ring already holds that seat, and the tweak is fully inert. Three companion CSS rules: `[class*="_dock"] > span:has(> button > svg[viewBox="0 0 14 14"] > circle) { visibility: hidden; width: 0; margin-left: -12px }` hides the shipped capsule — through `visibility` rather than `display`, and collapsing only its width, because the box must stay as a 22px height floor: the two stats presentations are 20px (legacy line) and 22px (pills) tall, so with the capsule's box fully gone every toggle of the stats rows — or a session with no stats at all — changes the composer's height and pushes the whole input area (the shipped capsule always occupied those 22px, which is exactly why stock 0.1.6-alpha.2 does not jump when the stats rows toggle). Keeping the real element means the floor follows a font-size preference for free, with no hard-coded height, while `visibility: hidden` also takes the capsule out of hit-testing and the accessibility tree; the `margin-left: -12px` cancels the dock's 12px gap so the stats row keeps sitting exactly on the card's centre line (the pre-alpha.2 line's position). Two `order` rules place the ported ring after the model chip and before the stop/send cluster (`conversation.input.right` cells render before the model chip; through the `display: contents` anchors the order declarations act on the visible boxes directly), reproducing alpha.1's in-row order exactly; the two are additionally gated on an ancestor that owns a dock child (`div:has(> div[class*="_dock"])`), so they never enter the cascade on earlier hosts rather than merely restating the native order. Layering with "no context pill hover info": that rule's anchor test matches the ported ring as well, so the one switch covers both presentations; that switch's row is a **dependent row** — while this tweak is on the panel no longer renders it (hidden, never greyed out, the rule the dependent numeric fields follow), because the capsule it names has been replaced and hidden and leaving the row would only mislead. The stored value is untouched: turning this tweak off brings the row back with its value.
@@ -332,4 +344,4 @@ This plugin was inspired by and built with reference to [wlj521/dsh-ui-tweaks](h
332
344
 
333
345
  ## License
334
346
 
335
- MIT
347
+ MIT