dsh-style-tweaks 0.1.1 → 0.1.3

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 (46) hide show
  1. package/README.en.md +212 -27
  2. package/README.md +193 -27
  3. package/lib/client.js +2034 -156
  4. package/lib/client.js.map +1 -1
  5. package/lib/config.js +62 -4
  6. package/lib/config.js.map +1 -1
  7. package/lib/types/client/index.d.ts +35 -6
  8. package/lib/types/client/index.d.ts.map +1 -1
  9. package/lib/types/client/settings-nav-icon.d.ts +87 -0
  10. package/lib/types/client/settings-nav-icon.d.ts.map +1 -0
  11. package/lib/types/client/tweak-config.d.ts +33 -1
  12. package/lib/types/client/tweak-config.d.ts.map +1 -1
  13. package/lib/types/client/tweak-types.d.ts +14 -0
  14. package/lib/types/client/tweak-types.d.ts.map +1 -1
  15. package/lib/types/client/tweaks/assistant-stream-timing.d.ts +105 -0
  16. package/lib/types/client/tweaks/assistant-stream-timing.d.ts.map +1 -0
  17. package/lib/types/client/tweaks/idle-send-flash-guard.d.ts +81 -0
  18. package/lib/types/client/tweaks/idle-send-flash-guard.d.ts.map +1 -0
  19. package/lib/types/client/tweaks/keep-turn-rail.d.ts +82 -0
  20. package/lib/types/client/tweaks/keep-turn-rail.d.ts.map +1 -0
  21. package/lib/types/client/tweaks/legacy-stats-line.d.ts +22 -6
  22. package/lib/types/client/tweaks/legacy-stats-line.d.ts.map +1 -1
  23. package/lib/types/client/tweaks/locate-current-session.d.ts +6 -5
  24. package/lib/types/client/tweaks/locate-current-session.d.ts.map +1 -1
  25. package/lib/types/client/tweaks/pills-cache-hit-decimals.d.ts +3 -2
  26. package/lib/types/client/tweaks/pills-cache-hit-decimals.d.ts.map +1 -1
  27. package/lib/types/client/tweaks/project-running-indicator.d.ts +50 -14
  28. package/lib/types/client/tweaks/project-running-indicator.d.ts.map +1 -1
  29. package/lib/types/client/tweaks/registry.d.ts.map +1 -1
  30. package/lib/types/client/tweaks/rightbar-initial-width.d.ts +99 -0
  31. package/lib/types/client/tweaks/rightbar-initial-width.d.ts.map +1 -0
  32. package/lib/types/client/tweaks/settings-nav-scroll.d.ts +6 -0
  33. package/lib/types/client/tweaks/settings-nav-scroll.d.ts.map +1 -1
  34. package/lib/types/client/tweaks/sidebar-middle-click-close.d.ts +105 -0
  35. package/lib/types/client/tweaks/sidebar-middle-click-close.d.ts.map +1 -0
  36. package/lib/types/client/tweaks/stable-turn-rail.d.ts +11 -5
  37. package/lib/types/client/tweaks/stable-turn-rail.d.ts.map +1 -1
  38. package/lib/types/client/tweaks/stats-cache-hit.d.ts +3 -2
  39. package/lib/types/client/tweaks/stats-cache-hit.d.ts.map +1 -1
  40. package/lib/types/client/tweaks/thinking-scroll.d.ts +4 -0
  41. package/lib/types/client/tweaks/thinking-scroll.d.ts.map +1 -0
  42. package/lib/types/client/tweaks/turn-speed-metrics.d.ts +67 -0
  43. package/lib/types/client/tweaks/turn-speed-metrics.d.ts.map +1 -0
  44. package/lib/types/config.d.ts +132 -20
  45. package/lib/types/config.d.ts.map +1 -1
  46. package/package.json +24 -23
package/README.en.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # dsh-style-tweaks
2
2
 
3
- > Dependency version: deepseek-harness v0.1.2-rc.1
3
+ English | [简体中文](README.md)
4
+
5
+ > Dependency version: deepseek-harness v0.1.5-rc.1
4
6
 
5
7
  A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) web plugin that bundles a precise conversation column-width control with a collection of opt-in style tweaks for DSH's UI — including small fixes for the sidebar and the settings panel.
6
8
 
@@ -8,36 +10,48 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) web
8
10
 
9
11
  ### Layout
10
12
 
11
- - **Plugin width control (default on)** — when ON, the plugin's width input / presets drive the conversation column and DSH's native drag handles are hidden; when OFF, DSH's native handles own the column and the width input mirrors their value.
12
- - **Dialog width** — any value between 600 and 1600 px; includes presets 748 (default) / 880 (Wide) / 1024 (Extra wide).
13
- - **Side margin** — whitespace in px kept on each side of the conversation area. The column is clamped to the dialog width and narrows when the sidebar opens or the window shrinks, never hugging the edges. Minimum 32 px.
13
+ - **Plugin width control (default off)** — when ON, the plugin's width input / presets drive the conversation column and DSH's native drag handles are hidden; when OFF (default), DSH's native handles own the column and the dialog-width setting is hidden. The last width is kept, so switching back on restores it.
14
+ - **Dialog width** — any value between 600 and 1600 px; includes presets 748 (default) / 880 (Wide) / 1024 (Extra wide). Shown only while plugin width control is on.
15
+ - **Side margin** — whitespace in px kept on each side of the conversation area while plugin width control is on. The column is clamped to the dialog width and narrows when the sidebar opens or the window shrinks, never hugging the edges. Minimum 32 px. Shown only while plugin width control is on; when it is off, the margin does not apply and DSH's native behavior stands.
16
+ - **Fixed think height (default off)** — caps the expanded think (reasoning) body at a fixed height (default 300 px, adjustable 120–1200) and scrolls the overflow, so a long thinking trace stops pushing the rest of the conversation out of view; while a trace is still streaming, the window shows its top and you scroll for the tail. The wheel chains naturally: when the body hits its top/bottom edge or its content fits the window, the wheel scrolls the conversation instead. Folding the row back to one line keeps working as usual. The window gets an always-visible 4px thin scrollbar (same as the settings nav), with a breathing gap between text and bar.
17
+ - **Right sidebar initial width (default off, 45%)** — when ON, owns the right sidebar's first-open width: it is written once, the first time the sidebar opens in a page load, as the session frame's width × a percentage (15–70, with 30 / 40 / 45 / 55 presets). A manual drag, and every later open, keeps your own width; a reload applies the percentage again. OFF by default, which leaves DSH's own 45% completely untouched; once ON, the default percentage is that same 45, so nothing changes until the number is edited. DSH still clamps the result into its own range (at least 300 px, at most 70% of the frame), so a conversion below 300 px renders 300 px wide. On pre-0.1.5 hosts the tweak stays inert.
14
18
 
15
19
  ### Tweaks
16
20
 
17
21
  - **Stable table layout (default on)** — locks table layout on hover so surrounding content does not reflow ("text jumps when I hover a table").
18
22
  - **Stable turn-navigation rail (default on)** — keeps the turn-navigation rail at a stable position when scrolling up past the first message into the system prompt area (no longer drops by ~16 px).
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.
19
24
  - **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.
20
- - **Project running indicator (default on)** — shows the conversation title's animated running dot on the right side of each project directory in the sidebar, so a running conversation stays visible even when its group is collapsed.
25
+ - **Project running indicator (default on)** — puts the conversation title's animated running dot into the sidebar. 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.
21
26
  - **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.
22
27
  - **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.
28
+ - **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.
23
29
  - **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.
24
- - **Cache hit with two decimals (pills, default off)** — shows the new stats pills' cache-hit share with two decimal places (e.g. `87.35%`) instead of integer rounding; the token-usage dialog follows. Hidden in Settings while the legacy stats line is on (that line replaces the pills outright); with both toggles on, the legacy line wins.
30
+ - **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.
31
+ - **Turn speed & TTFT (default off)** — since 0.1.5, cold sessions no longer rebuild per-token timing, so each turn's time dialog keeps only the wall-clock duration. Clicking a turn's time pill refills that dialog with the output speed and TTFT rows, rebuilt from the model stream embedded in the session log; history loads get the same figures a live session did.
25
32
 
26
33
  ```yaml
27
34
  style-tweaks:
28
35
  # Layout
29
- usePluginWidth: true # default true; false hands the column back to native handles
30
- dialogWidth: 748 # 600–1600 px
31
- sideMargin: 50 # ≥ 32 px
36
+ usePluginWidth: false # default false; true lets the plugin input/presets own the column
37
+ dialogWidth: 748 # 600–1600 px; shown/effective only while usePluginWidth is on
38
+ sideMargin: 50 # ≥ 32 px; shown/effective only while usePluginWidth is on
39
+ thinkFixedHeight: false # default false; true caps the think body at a fixed height and scrolls it
40
+ thinkHeight: 300 # 120–1200 px; shown/effective only while thinkFixedHeight is on
41
+ rightbarInitialWidth: false # default false; true lets the plugin own the right sidebar's first-open width
42
+ rightbarWidthPercent: 45 # 15–70; right sidebar first-open width as a percentage of the session frame
32
43
  # Tweaks
33
44
  stableTable: true # default true; false disables the tweak
34
45
  stableTurnRail: true # default true; false disables the tweak
46
+ keepTurnRail: false # default false; true keeps the turn-navigation rail at every chat width
35
47
  codeBlockFlushTop: true # default true; false disables the tweak
36
48
  projectRunningIndicator: true # default true; false disables the tweak
37
49
  locateCurrentSession: true # default true; false hides the sidebar locate button
38
50
  settingsNavScroll: true # default true; false disables the settings-nav scrolling
51
+ sidebarMiddleClickClose: true # default true; false disables middle-click tab closing
39
52
  legacyStatsLine: false # default false; true swaps the pills for the 0.1.2 text line
40
- pillsCacheHitDecimals: false # default false; true shows the pills' cache hit with two decimals
53
+ pillsCacheHitDecimals: false # default false; true shows the cache hit with two decimals (pills and legacy line)
54
+ turnSpeedMetrics: false # default false; true refills the turn-time dialog with output speed and TTFT
41
55
  ```
42
56
 
43
57
  Settings entry: **Settings → Style tweaks**.
@@ -46,22 +60,26 @@ Settings entry: **Settings → Style tweaks**.
46
60
 
47
61
  ```bash
48
62
  # from npm (recommended, prebuilt)
49
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks
63
+ dsh plugin --profile web add dsh-style-tweaks
50
64
 
51
65
  # from GitHub (source; runs the self-contained prepare build)
52
- npx -y @deepseek-ai/dsh plugin --profile web add github:zhj9709/dsh-style-tweaks
66
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks
53
67
  ```
54
68
 
55
69
  The package spec after `add` is forwarded to pnpm verbatim, so versions can be
56
70
  pinned — `@version` for the npm package, `#tag` for the GitHub source:
57
71
 
58
72
  ```bash
59
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks@0.1.0 # pin the npm version
60
- npx -y @deepseek-ai/dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.0 # pin a git tag
73
+ dsh plugin --profile web add dsh-style-tweaks@0.1.3 # pin the npm version
74
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.3 # pin a git tag
61
75
  ```
62
76
 
63
77
  Restart DSH web once after installing (bundle plugins are scanned at process start).
64
78
 
79
+ > The commands above assume `dsh` is on your PATH. Without a global install, replace the leading
80
+ > `dsh` with `npx -y @deepseek-ai/dsh` — e.g.
81
+ > `npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks`.
82
+
65
83
  ## Development
66
84
 
67
85
  ### Build
@@ -72,32 +90,196 @@ pnpm build # tsc (server) + tsc (client) + bundle lib/client.js
72
90
  pnpm typecheck
73
91
  ```
74
92
 
75
- Load against a running DSH with an overlay, or install as a bundle:
93
+ Load against a running DSH with an overlay, or install as a symlink:
94
+
95
+ ```bash
96
+ dsh web --patch ./cordis.patch.yml # dev overlay
97
+ dsh plugin --profile web add "link:./" # symlink install (local dev, see below)
98
+ ```
99
+
100
+ ### Local development (recommended): `link:` symlink + hot reload
101
+
102
+ While developing, **do not pack the repo into a `.tgz` and install that**, and do not copy files into
103
+ the profile by hand after each build. Point the profile's `node_modules/dsh-style-tweaks` at this
104
+ checkout with pnpm's `link:` protocol instead — build output then lands on the very path DSH loads,
105
+ so a code change needs no copy, no repack, and no reinstall.
106
+
107
+ `<profile>` below stands for the profile directory:
108
+
109
+ | Platform | Profile directory |
110
+ |---|---|
111
+ | macOS / Linux | `~/.dsh/profiles/web` |
112
+ | Windows | `%USERPROFILE%\.dsh\profiles\web` |
113
+
114
+ **One-time install** (run from the repository root; the spec after `add` is forwarded to pnpm verbatim):
76
115
 
77
116
  ```bash
78
- npx -y @deepseek-ai/dsh web --patch ./cordis.patch.yml # dev overlay
79
- npx -y @deepseek-ai/dsh plugin --profile web add . # bundle install from this checkout
117
+ dsh plugin --profile web add "link:./"
80
118
  ```
81
119
 
82
- ### Hot Reload (no reinstall needed)
120
+ This writes both the profile's `dependencies` and its `dsh.profile.bundles`, and has pnpm create the
121
+ symlink; whether `link:./` ends up recorded as a relative or an absolute path depends on the CLI, so
122
+ verify it after installing with the commands below.
123
+
124
+ > **Creating the symlink requires**: on Windows, **Developer Mode** (Settings → System → For
125
+ > developers) or an elevated shell, otherwise pnpm may fail to create a real symlink (some versions
126
+ > fall back to a junction, which is functionally equivalent); macOS / Linux have no such restriction.
83
127
 
84
- Only the client plugin (`lib/client.js`) needs to be loaded by the DSH runtime. After each code change:
128
+ > **Only needed if this plugin was ever disabled by hand**: DSH composes its tree as
129
+ > `dsh.profile.bundles` → `cordis.patch.yml` → `--patch` overlays, so the patch layer is applied last,
130
+ > while `dsh plugin add/remove` only maintains `dependencies` and `dsh.profile.bundles` and never
131
+ > touches that hand-written file. A leftover `disabled: true` therefore survives a remove → add and
132
+ > shows up as "installed, built — and nothing mounts in the page".
133
+ >
134
+ > The file to edit is the **profile's** `<profile>/cordis.patch.yml`, **not** the same-named file in
135
+ > the repository root (that one is this bundle's own patch — a single `insert`, with no `disabled`).
136
+ > Check first with:
137
+
138
+ ```bash
139
+ grep -n "style-tweaks" ~/.dsh/profiles/web/cordis.patch.yml # macOS / Linux
140
+ ```
141
+
142
+ ```powershell
143
+ Select-String "$env:USERPROFILE\.dsh\profiles\web\cordis.patch.yml" -Pattern style-tweaks
144
+ ```
145
+
146
+ > Delete only that entry; keep the other disables (they are deliberate).
147
+
148
+ Verify the symlink points at this repo:
149
+
150
+ **macOS / Linux (including WSL and Git Bash)**
151
+
152
+ ```bash
153
+ link=~/.dsh/profiles/web/node_modules/dsh-style-tweaks
154
+ ls -ld "$link" # expect an l... entry whose -> points at this repo
155
+ readlink "$link" # expect the repository root
156
+ test -e "$link/src" && echo "src is reachable"
157
+ ```
158
+
159
+ **Windows (PowerShell)**
160
+
161
+ ```powershell
162
+ $link = "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-style-tweaks"
163
+ (Get-Item $link).LinkType # expect SymbolicLink (or Junction)
164
+ (Get-Item $link).Target # expect the repository root
165
+ Test-Path "$link\src" # expect True, proving it really points at the sources
166
+ ```
167
+
168
+ **The hot-reload loop after every code change:**
85
169
 
86
170
  1. **Build**:
171
+
87
172
  ```bash
88
173
  pnpm build
89
174
  ```
90
175
 
91
- 2. **Copy to the profile directory**:
176
+ This step is not optional. The repo ships **no** `dev` / `watch` script — nothing watches `src/`
177
+ and recompiles for you, so a source change stays invisible until you build by hand (the symptom is
178
+ "I changed it and nothing happened"). Under the symlink, `pnpm build` rewrites `lib/client.js` in
179
+ place — the very file DSH loads — so **no copy into the profile is needed any more**.
180
+
181
+ 2. **Make the page load the bundle again**:
182
+
183
+ - **Hard-refresh the GUI page first** — `Ctrl+Shift+R` on Windows / Linux, `Cmd+Shift+R` on macOS
184
+ (or close the tab and reopen it). In practice this is the most common and most reliable step;
185
+ a refresh is enough in the vast majority of cases.
186
+ - Only if a refresh still does not take effect should you restart the `dsh web` process (stop it
187
+ and run it again).
188
+
189
+ The claim in older versions of this README — that "DSH's client-plugin HMR receiver detects the
190
+ change and reloads automatically" — **often does not fire on a local setup**: `dsh web` usually runs
191
+ from an installed package rather than a source checkout running `dev:web`, so the page keeps using
192
+ the bundle it already fetched. "No change until I refresh" is therefore normal and does not mean
193
+ your code failed. A symlink only guarantees the file **on disk** is new; it does not guarantee the
194
+ page **fetches it again**.
195
+
196
+ **How to confirm the new code is really loaded**:
197
+
198
+ - The page should carry injected styles such as `style[data-tweak-css="cst-*"]`, plus the
199
+ `__cst_*_cleanup__` guards each tweak leaves on `window` (e.g.
200
+ `__cst_project_running_indicator_cleanup__`). A guard that is `undefined` means that tweak never
201
+ mounted.
202
+ - To see which build the page actually got: take the full URL of the
203
+ `/plugins/??…dsh-style-tweaks…` entry from `performance.getEntriesByType('resource')`, then
204
+ `fetch(url, { cache: 'no-store' })` and look for an identifier you just added. The page does
205
+ **not** re-request that URL after a build — which is itself a direct way to tell whether HMR fired.
206
+ - Overwriting `lib/client.js` exactly while DSH is reading the bundle can produce one bogus
207
+ "the whole plugin is gone" pass (no guards, no styles). A single refresh restores it; don't blame
208
+ your code yet.
209
+
210
+ **Boundaries**: only the client artifact `lib/client.js` hot-loads this way. Server code
211
+ (`src/index.ts` / `src/web.ts` / `src/config.ts` → `lib/index.js`) needs a `dsh web` restart on top of
212
+ `pnpm build`; changes to the `apps/web` shell or to a plain package also require rebuilding the
213
+ corresponding web artifacts and refreshing the page.
214
+
215
+ **Back to the published version** (package name, no local path):
216
+
217
+ ```bash
218
+ dsh plugin --profile web remove dsh-style-tweaks
219
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks
220
+ ```
221
+
222
+ ### Alternative: install from a packed `.tgz` (artifact copy required)
223
+
224
+ When you would rather not have the profile depend on this repo's path — or want to exercise a real
225
+ install end to end — pack the repo into a `.tgz` and install that. The trade-off: the profile's
226
+ `node_modules/dsh-style-tweaks` becomes a **real copy** (no longer pointing at the sources), so every
227
+ source change has to be copied over — exactly the step the `link:` setup spares you.
228
+
229
+ ```bash
230
+ pnpm build
231
+ pnpm pack # produces dsh-style-tweaks-<version>.tgz (gitignored; do not commit)
232
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.3.tgz
233
+ ```
234
+
235
+ **Restart `dsh web` once** after installing (bundle plugins are scanned at process start).
236
+
237
+ Then, after each code change:
238
+
239
+ 1. **Build**:
240
+
92
241
  ```bash
93
- cp lib/client.js ~/.dsh/profiles/web/node_modules/dsh-style-tweaks/lib/client.js
242
+ pnpm build
94
243
  ```
95
244
 
96
- 3. **DSH's client-plugin HMR receiver** detects the change and automatically reloads the plugin — no reinstall required.
245
+ 2. **Copy the artifact into the profile** (not needed under `link:`). Delete first rather than
246
+ overwriting in place:
247
+
248
+ **macOS / Linux**
97
249
 
98
- > Note: Sometimes the GUI process caches the old bundle, making it appear that changes have no effect. In this case, restart the `dsh web` process.
250
+ ```bash
251
+ dest=~/.dsh/profiles/web/node_modules/dsh-style-tweaks/lib/client.js
252
+ rm -f "$dest" && cp lib/client.js "$dest"
253
+ ```
254
+
255
+ **Windows (PowerShell)**
256
+
257
+ ```powershell
258
+ $dest = "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-style-tweaks\lib\client.js"
259
+ Remove-Item $dest -Force
260
+ Copy-Item lib/client.js $dest
261
+ ```
262
+
263
+ 3. **Hard-refresh the GUI page**; if that still does not take effect, restart `dsh web`.
264
+
265
+ > **Why not simply overwrite with `cp -f`**: pnpm links files in `node_modules` to its
266
+ > content-addressable store — by **hard link** on Linux and Windows — so an in-place overwrite writes
267
+ > through to the store and can corrupt it for other projects sharing those files. macOS defaults to
268
+ > APFS clonefile (copy-on-write) and is usually unaffected, but "delete then copy" is safe everywhere.
269
+ > On GNU coreutils you can also use `cp --remove-destination`; note that **BSD `cp` on macOS has no
270
+ > such option**.
271
+
272
+ Switching from `link:` back to `.tgz` needs a `remove` first, since the profile already holds a local
273
+ path dependency:
274
+
275
+ ```bash
276
+ dsh plugin --profile web remove dsh-style-tweaks
277
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.3.tgz
278
+ ```
99
279
 
100
- Only client plugins (`client.js`) support hot reloading. Changes to the `apps/web` shell or plain packages still require rebuilding web artifacts and a page refresh.
280
+ The only difference between the two setups is whether the artifact lands in place automatically: both
281
+ need `pnpm build` and a page refresh. Use `link:` for day-to-day code changes, and `.tgz` when you want
282
+ to verify the published package itself.
101
283
 
102
284
  ## How it works
103
285
 
@@ -105,11 +287,14 @@ Only client plugins (`client.js`) support hot reloading. Changes to the `apps/we
105
287
  - **Browser** (`src/client/index.tsx`) reads/writes that route, renders the Settings section, and mounts / unmounts each tweak live based on its toggle (pure-CSS tweaks inject runtime `<style>` elements; JS-level tweaks also read the app's own state stores and patch the DOM).
106
288
  - **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.
107
289
  - **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/`.
108
- - **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` (re-using `@deepseek-ai/dsh-client-ui-primitives` so the animation keyframes and style tokens are byte-identical to the conversation title's dot) inside each project directory header row (`role="treeitem"[aria-expanded]`, a stable hand-written attribute).
290
+ - **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` (re-using `@deepseek-ai/dsh-client-ui-primitives` so the animation keyframes and style tokens are byte-identical to the conversation title's dot) 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 keep matching workspaces by title text, exactly as before. 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.
109
291
  - **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 reads the current session title from the breadcrumb's disabled crumb, resolves the parent workspace via `ctx.get('sessions')` / `ctx.get('workspaces')` app stores (works even when the workspace's sidebar group is collapsed), finds the workspace row by exact title match, 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' })`. 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 MutationObserver watches `aria-selected` / `aria-expanded` attribute changes to keep the button's enabled state and mount in sync.
110
292
  - **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`.
111
- - **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 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 2px of bottom padding so its total matches the pills row (26px row + 4px host clearance on both presentations) — toggling the tweak no longer shifts the conversation.
112
- - **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. One icon note: the pills' gauge icon only exists in primitives 0.1.3+, while the plugin typechecks against 0.1.2-rc.1 — at runtime the host's `IconGaugeOutline16` is used when present, falling back to a clock icon. 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. Settings hides this toggle while the legacy line is on.
293
+ - **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.
294
+ - **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.
295
+ - **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 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 2px of bottom padding so its total matches the pills row (26px row + 4px host clearance on both presentations) — toggling the tweak no longer shifts the conversation. 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.
296
+ - **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. 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.
297
+ - **Turn speed & TTFT** (`src/client/tweaks/turn-speed-metrics.tsx` + `src/client/tweaks/assistant-stream-timing.ts`): session format v2 (0.1.5) 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 v2 architecture 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. This tweak is the consumer that note anticipates: it reads the settlements out of the session binding's documented event window (`ctx.sessions.binding(sessionId).eventSource`, the same feed the Conversation assembly consumes), with the compact-stream readers ported verbatim from dsh-llm (`assistantStreamFirstTokenTime`: reconstructs the first token's time inside a packed run from `time0` plus the `dt` gap list) and the fold from 0.1.5's `deriveTurnMetrics` — 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. Everything rides the durable log, so history loads and freshly settled turns show identical figures. Display choice: the dialog belongs to `TurnTimePanel`, which offers no slot, so the tweak mounts an invisible controller on the `conversation.chat.assistant-actions` list slot (inside each turn's action row; list entries render additively, no election race with other plugins' entries). A capture click listener on the footer only hands that footer to one shared body-wide `MutationObserver` (the dialog is portaled to `<body>`, so the panel node cannot reach its footer through `closest()`); the observer callback is a microtask that always runs before the next frame's rendering steps, claims the freshly committed panel by its stable `data-turn-time-details` marker, and appends `<dt>/<dd>` pairs after the duration row — labels straight from DSH's surviving `chat` vocabulary (`message.turnTime.speed` / `message.turnTime.ttft` / `message.tokensPerSecond` / `duration.seconds`), values inheriting the dialog's own `.details dt/.details dd` grid rules, so the refilled panel reads exactly like the 0.1.2 one. Appending rows grows the panel, and `useAnchoredPosition` repositions through `ResizeObserver` → `setState`, whose render lands after the next paint — the panel would otherwise paint one frame at its stale `top` (48 px too low) before snapping up, the very flash this tweak shipped with — so the same microtask lifts the fixed-positioned card by the added height: the dialog hangs from the trigger's top edge (`side: 'top'`), so keeping the card's bottom edge fixed is exactly the value the host's own `place()` computes for the taller panel, the first painted frame is already final, and the host's later write becomes a same-value no-op; the lift is skipped only when it would push the card above the viewport (a clamped placement). Hiding the card until the host repainted was tried and rejected — it turns a correct instant open into a visible late pop, making turns that never flashed start flashing. Do not derive the shift from footer geometry either: `data-turn-tail` is the whole turn container, not the action row. Turns whose live timing already survived render their own rows and are skipped by label. The rows are plain DOM inside the React-owned panel: they die with the dialog and re-inject on every open; with no derivable figure (no token in the stream, or the settlement outside the loaded window) nothing is injected, and turning the tweak off restores the stock behaviour.
113
298
 
114
299
  ## Acknowledgements
115
300