dsh-style-tweaks 0.1.2 → 0.1.4

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 (37) hide show
  1. package/README.en.md +196 -17
  2. package/README.md +177 -17
  3. package/lib/client.js +2066 -208
  4. package/lib/client.js.map +1 -1
  5. package/lib/config.js +51 -0
  6. package/lib/config.js.map +1 -1
  7. package/lib/types/client/index.d.ts +37 -1
  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 +24 -0
  12. package/lib/types/client/tweak-config.d.ts.map +1 -1
  13. package/lib/types/client/tweak-types.d.ts +10 -0
  14. package/lib/types/client/tweak-types.d.ts.map +1 -1
  15. package/lib/types/client/tweaks/idle-send-flash-guard.d.ts +81 -0
  16. package/lib/types/client/tweaks/idle-send-flash-guard.d.ts.map +1 -0
  17. package/lib/types/client/tweaks/keep-turn-rail.d.ts +82 -0
  18. package/lib/types/client/tweaks/keep-turn-rail.d.ts.map +1 -0
  19. package/lib/types/client/tweaks/locate-current-session.d.ts +10 -6
  20. package/lib/types/client/tweaks/locate-current-session.d.ts.map +1 -1
  21. package/lib/types/client/tweaks/project-running-indicator.d.ts +63 -21
  22. package/lib/types/client/tweaks/project-running-indicator.d.ts.map +1 -1
  23. package/lib/types/client/tweaks/registry.d.ts.map +1 -1
  24. package/lib/types/client/tweaks/rightbar-initial-width.d.ts +99 -0
  25. package/lib/types/client/tweaks/rightbar-initial-width.d.ts.map +1 -0
  26. package/lib/types/client/tweaks/settings-nav-scroll.d.ts +6 -0
  27. package/lib/types/client/tweaks/settings-nav-scroll.d.ts.map +1 -1
  28. package/lib/types/client/tweaks/stable-turn-rail.d.ts +11 -5
  29. package/lib/types/client/tweaks/stable-turn-rail.d.ts.map +1 -1
  30. package/lib/types/client/tweaks/workspace-close.d.ts +99 -0
  31. package/lib/types/client/tweaks/workspace-close.d.ts.map +1 -0
  32. package/lib/types/config.d.ts +101 -6
  33. package/lib/types/config.d.ts.map +1 -1
  34. package/lib/types/web.d.ts.map +1 -1
  35. package/lib/web.js +10 -1
  36. package/lib/web.js.map +1 -1
  37. package/package.json +3 -2
package/README.en.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-style-tweaks
2
2
 
3
+ English | [简体中文](README.md)
4
+
3
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.
@@ -12,19 +14,22 @@ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) web
12
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.
13
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.
14
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.
15
18
 
16
19
  ### Tweaks
17
20
 
18
21
  - **Stable table layout (default on)** — locks table layout on hover so surrounding content does not reflow ("text jumps when I hover a table").
19
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.
20
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.
21
- - **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.
22
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.
23
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.
24
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.
25
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.
26
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.
27
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.
32
+ - **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).
28
33
 
29
34
  ```yaml
30
35
  style-tweaks:
@@ -34,9 +39,12 @@ style-tweaks:
34
39
  sideMargin: 50 # ≥ 32 px; shown/effective only while usePluginWidth is on
35
40
  thinkFixedHeight: false # default false; true caps the think body at a fixed height and scrolls it
36
41
  thinkHeight: 300 # 120–1200 px; shown/effective only while thinkFixedHeight is on
42
+ rightbarInitialWidth: false # default false; true lets the plugin own the right sidebar's first-open width
43
+ rightbarWidthPercent: 45 # 15–70; right sidebar first-open width as a percentage of the session frame
37
44
  # Tweaks
38
45
  stableTable: true # default true; false disables the tweak
39
46
  stableTurnRail: true # default true; false disables the tweak
47
+ keepTurnRail: false # default false; true keeps the turn-navigation rail at every chat width
40
48
  codeBlockFlushTop: true # default true; false disables the tweak
41
49
  projectRunningIndicator: true # default true; false disables the tweak
42
50
  locateCurrentSession: true # default true; false hides the sidebar locate button
@@ -45,6 +53,8 @@ style-tweaks:
45
53
  legacyStatsLine: false # default false; true swaps the pills for the 0.1.2 text line
46
54
  pillsCacheHitDecimals: false # default false; true shows the cache hit with two decimals (pills and legacy line)
47
55
  turnSpeedMetrics: false # default false; true refills the turn-time dialog with output speed and TTFT
56
+ workspaceClose: false # default false; true enables closing (hiding) a Workspace from its row menu
57
+ closedWorkspaces: [] # ids of closed Workspaces (internal data; managed by the panel's list)
48
58
  ```
49
59
 
50
60
  Settings entry: **Settings → Style tweaks**.
@@ -53,22 +63,26 @@ Settings entry: **Settings → Style tweaks**.
53
63
 
54
64
  ```bash
55
65
  # from npm (recommended, prebuilt)
56
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks
66
+ dsh plugin --profile web add dsh-style-tweaks
57
67
 
58
68
  # from GitHub (source; runs the self-contained prepare build)
59
- npx -y @deepseek-ai/dsh plugin --profile web add github:zhj9709/dsh-style-tweaks
69
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks
60
70
  ```
61
71
 
62
72
  The package spec after `add` is forwarded to pnpm verbatim, so versions can be
63
73
  pinned — `@version` for the npm package, `#tag` for the GitHub source:
64
74
 
65
75
  ```bash
66
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks@0.1.0 # pin the npm version
67
- npx -y @deepseek-ai/dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.0 # pin a git tag
76
+ dsh plugin --profile web add dsh-style-tweaks@0.1.4 # pin the npm version
77
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.4 # pin a git tag
68
78
  ```
69
79
 
70
80
  Restart DSH web once after installing (bundle plugins are scanned at process start).
71
81
 
82
+ > The commands above assume `dsh` is on your PATH. Without a global install, replace the leading
83
+ > `dsh` with `npx -y @deepseek-ai/dsh` — e.g.
84
+ > `npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks`.
85
+
72
86
  ## Development
73
87
 
74
88
  ### Build
@@ -79,32 +93,196 @@ pnpm build # tsc (server) + tsc (client) + bundle lib/client.js
79
93
  pnpm typecheck
80
94
  ```
81
95
 
82
- Load against a running DSH with an overlay, or install as a bundle:
96
+ Load against a running DSH with an overlay, or install as a symlink:
97
+
98
+ ```bash
99
+ dsh web --patch ./cordis.patch.yml # dev overlay
100
+ dsh plugin --profile web add "link:./" # symlink install (local dev, see below)
101
+ ```
102
+
103
+ ### Local development (recommended): `link:` symlink + hot reload
104
+
105
+ While developing, **do not pack the repo into a `.tgz` and install that**, and do not copy files into
106
+ the profile by hand after each build. Point the profile's `node_modules/dsh-style-tweaks` at this
107
+ checkout with pnpm's `link:` protocol instead — build output then lands on the very path DSH loads,
108
+ so a code change needs no copy, no repack, and no reinstall.
109
+
110
+ `<profile>` below stands for the profile directory:
111
+
112
+ | Platform | Profile directory |
113
+ |---|---|
114
+ | macOS / Linux | `~/.dsh/profiles/web` |
115
+ | Windows | `%USERPROFILE%\.dsh\profiles\web` |
116
+
117
+ **One-time install** (run from the repository root; the spec after `add` is forwarded to pnpm verbatim):
83
118
 
84
119
  ```bash
85
- npx -y @deepseek-ai/dsh web --patch ./cordis.patch.yml # dev overlay
86
- npx -y @deepseek-ai/dsh plugin --profile web add . # bundle install from this checkout
120
+ dsh plugin --profile web add "link:./"
87
121
  ```
88
122
 
89
- ### Hot Reload (no reinstall needed)
123
+ This writes both the profile's `dependencies` and its `dsh.profile.bundles`, and has pnpm create the
124
+ symlink; whether `link:./` ends up recorded as a relative or an absolute path depends on the CLI, so
125
+ verify it after installing with the commands below.
126
+
127
+ > **Creating the symlink requires**: on Windows, **Developer Mode** (Settings → System → For
128
+ > developers) or an elevated shell, otherwise pnpm may fail to create a real symlink (some versions
129
+ > fall back to a junction, which is functionally equivalent); macOS / Linux have no such restriction.
90
130
 
91
- Only the client plugin (`lib/client.js`) needs to be loaded by the DSH runtime. After each code change:
131
+ > **Only needed if this plugin was ever disabled by hand**: DSH composes its tree as
132
+ > `dsh.profile.bundles` → `cordis.patch.yml` → `--patch` overlays, so the patch layer is applied last,
133
+ > while `dsh plugin add/remove` only maintains `dependencies` and `dsh.profile.bundles` and never
134
+ > touches that hand-written file. A leftover `disabled: true` therefore survives a remove → add and
135
+ > shows up as "installed, built — and nothing mounts in the page".
136
+ >
137
+ > The file to edit is the **profile's** `<profile>/cordis.patch.yml`, **not** the same-named file in
138
+ > the repository root (that one is this bundle's own patch — a single `insert`, with no `disabled`).
139
+ > Check first with:
140
+
141
+ ```bash
142
+ grep -n "style-tweaks" ~/.dsh/profiles/web/cordis.patch.yml # macOS / Linux
143
+ ```
144
+
145
+ ```powershell
146
+ Select-String "$env:USERPROFILE\.dsh\profiles\web\cordis.patch.yml" -Pattern style-tweaks
147
+ ```
148
+
149
+ > Delete only that entry; keep the other disables (they are deliberate).
150
+
151
+ Verify the symlink points at this repo:
152
+
153
+ **macOS / Linux (including WSL and Git Bash)**
154
+
155
+ ```bash
156
+ link=~/.dsh/profiles/web/node_modules/dsh-style-tweaks
157
+ ls -ld "$link" # expect an l... entry whose -> points at this repo
158
+ readlink "$link" # expect the repository root
159
+ test -e "$link/src" && echo "src is reachable"
160
+ ```
161
+
162
+ **Windows (PowerShell)**
163
+
164
+ ```powershell
165
+ $link = "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-style-tweaks"
166
+ (Get-Item $link).LinkType # expect SymbolicLink (or Junction)
167
+ (Get-Item $link).Target # expect the repository root
168
+ Test-Path "$link\src" # expect True, proving it really points at the sources
169
+ ```
170
+
171
+ **The hot-reload loop after every code change:**
92
172
 
93
173
  1. **Build**:
174
+
94
175
  ```bash
95
176
  pnpm build
96
177
  ```
97
178
 
98
- 2. **Copy to the profile directory**:
179
+ This step is not optional. The repo ships **no** `dev` / `watch` script — nothing watches `src/`
180
+ and recompiles for you, so a source change stays invisible until you build by hand (the symptom is
181
+ "I changed it and nothing happened"). Under the symlink, `pnpm build` rewrites `lib/client.js` in
182
+ place — the very file DSH loads — so **no copy into the profile is needed any more**.
183
+
184
+ 2. **Make the page load the bundle again**:
185
+
186
+ - **Hard-refresh the GUI page first** — `Ctrl+Shift+R` on Windows / Linux, `Cmd+Shift+R` on macOS
187
+ (or close the tab and reopen it). In practice this is the most common and most reliable step;
188
+ a refresh is enough in the vast majority of cases.
189
+ - Only if a refresh still does not take effect should you restart the `dsh web` process (stop it
190
+ and run it again).
191
+
192
+ The claim in older versions of this README — that "DSH's client-plugin HMR receiver detects the
193
+ change and reloads automatically" — **often does not fire on a local setup**: `dsh web` usually runs
194
+ from an installed package rather than a source checkout running `dev:web`, so the page keeps using
195
+ the bundle it already fetched. "No change until I refresh" is therefore normal and does not mean
196
+ your code failed. A symlink only guarantees the file **on disk** is new; it does not guarantee the
197
+ page **fetches it again**.
198
+
199
+ **How to confirm the new code is really loaded**:
200
+
201
+ - The page should carry injected styles such as `style[data-tweak-css="cst-*"]`, plus the
202
+ `__cst_*_cleanup__` guards each tweak leaves on `window` (e.g.
203
+ `__cst_project_running_indicator_cleanup__`). A guard that is `undefined` means that tweak never
204
+ mounted.
205
+ - To see which build the page actually got: take the full URL of the
206
+ `/plugins/??…dsh-style-tweaks…` entry from `performance.getEntriesByType('resource')`, then
207
+ `fetch(url, { cache: 'no-store' })` and look for an identifier you just added. The page does
208
+ **not** re-request that URL after a build — which is itself a direct way to tell whether HMR fired.
209
+ - Overwriting `lib/client.js` exactly while DSH is reading the bundle can produce one bogus
210
+ "the whole plugin is gone" pass (no guards, no styles). A single refresh restores it; don't blame
211
+ your code yet.
212
+
213
+ **Boundaries**: only the client artifact `lib/client.js` hot-loads this way. Server code
214
+ (`src/index.ts` / `src/web.ts` / `src/config.ts` → `lib/index.js`) needs a `dsh web` restart on top of
215
+ `pnpm build`; changes to the `apps/web` shell or to a plain package also require rebuilding the
216
+ corresponding web artifacts and refreshing the page.
217
+
218
+ **Back to the published version** (package name, no local path):
219
+
220
+ ```bash
221
+ dsh plugin --profile web remove dsh-style-tweaks
222
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks
223
+ ```
224
+
225
+ ### Alternative: install from a packed `.tgz` (artifact copy required)
226
+
227
+ When you would rather not have the profile depend on this repo's path — or want to exercise a real
228
+ install end to end — pack the repo into a `.tgz` and install that. The trade-off: the profile's
229
+ `node_modules/dsh-style-tweaks` becomes a **real copy** (no longer pointing at the sources), so every
230
+ source change has to be copied over — exactly the step the `link:` setup spares you.
231
+
232
+ ```bash
233
+ pnpm build
234
+ pnpm pack # produces dsh-style-tweaks-<version>.tgz (gitignored; do not commit)
235
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.4.tgz
236
+ ```
237
+
238
+ **Restart `dsh web` once** after installing (bundle plugins are scanned at process start).
239
+
240
+ Then, after each code change:
241
+
242
+ 1. **Build**:
243
+
99
244
  ```bash
100
- cp lib/client.js ~/.dsh/profiles/web/node_modules/dsh-style-tweaks/lib/client.js
245
+ pnpm build
101
246
  ```
102
247
 
103
- 3. **DSH's client-plugin HMR receiver** detects the change and automatically reloads the plugin — no reinstall required.
248
+ 2. **Copy the artifact into the profile** (not needed under `link:`). Delete first rather than
249
+ overwriting in place:
250
+
251
+ **macOS / Linux**
104
252
 
105
- > 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.
253
+ ```bash
254
+ dest=~/.dsh/profiles/web/node_modules/dsh-style-tweaks/lib/client.js
255
+ rm -f "$dest" && cp lib/client.js "$dest"
256
+ ```
257
+
258
+ **Windows (PowerShell)**
259
+
260
+ ```powershell
261
+ $dest = "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-style-tweaks\lib\client.js"
262
+ Remove-Item $dest -Force
263
+ Copy-Item lib/client.js $dest
264
+ ```
265
+
266
+ 3. **Hard-refresh the GUI page**; if that still does not take effect, restart `dsh web`.
267
+
268
+ > **Why not simply overwrite with `cp -f`**: pnpm links files in `node_modules` to its
269
+ > content-addressable store — by **hard link** on Linux and Windows — so an in-place overwrite writes
270
+ > through to the store and can corrupt it for other projects sharing those files. macOS defaults to
271
+ > APFS clonefile (copy-on-write) and is usually unaffected, but "delete then copy" is safe everywhere.
272
+ > On GNU coreutils you can also use `cp --remove-destination`; note that **BSD `cp` on macOS has no
273
+ > such option**.
274
+
275
+ Switching from `link:` back to `.tgz` needs a `remove` first, since the profile already holds a local
276
+ path dependency:
277
+
278
+ ```bash
279
+ dsh plugin --profile web remove dsh-style-tweaks
280
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.4.tgz
281
+ ```
106
282
 
107
- 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.
283
+ The only difference between the two setups is whether the artifact lands in place automatically: both
284
+ need `pnpm build` and a page refresh. Use `link:` for day-to-day code changes, and `.tgz` when you want
285
+ to verify the published package itself.
108
286
 
109
287
  ## How it works
110
288
 
@@ -112,10 +290,11 @@ Only client plugins (`client.js`) support hot reloading. Changes to the `apps/we
112
290
  - **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).
113
291
  - **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.
114
292
  - **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/`.
115
- - **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).
116
- - **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.
293
+ - **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 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.
294
+ - **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 **id** via `ctx.get('sessions')` / `ctx.get('workspaces')` app stores (works even when the workspace's sidebar group is collapsed), finds the workspace row 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 — 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.
117
295
  - **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`.
118
296
  - **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.
297
+ - **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.
119
298
  - **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.
120
299
  - **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.
121
300
  - **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.