dsh-style-tweaks 0.1.2 → 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 (32) hide show
  1. package/README.en.md +192 -16
  2. package/README.md +173 -16
  3. package/lib/client.js +1085 -123
  4. package/lib/client.js.map +1 -1
  5. package/lib/config.js +29 -0
  6. package/lib/config.js.map +1 -1
  7. package/lib/types/client/index.d.ts +14 -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 +15 -0
  12. package/lib/types/client/tweak-config.d.ts.map +1 -1
  13. package/lib/types/client/tweak-types.d.ts +6 -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 +6 -5
  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 +50 -14
  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/config.d.ts +54 -0
  31. package/lib/types/config.d.ts.map +1 -1
  32. package/package.json +2 -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,13 +14,15 @@ 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.
@@ -34,9 +38,12 @@ style-tweaks:
34
38
  sideMargin: 50 # ≥ 32 px; shown/effective only while usePluginWidth is on
35
39
  thinkFixedHeight: false # default false; true caps the think body at a fixed height and scrolls it
36
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
37
43
  # Tweaks
38
44
  stableTable: true # default true; false disables the tweak
39
45
  stableTurnRail: true # default true; false disables the tweak
46
+ keepTurnRail: false # default false; true keeps the turn-navigation rail at every chat width
40
47
  codeBlockFlushTop: true # default true; false disables the tweak
41
48
  projectRunningIndicator: true # default true; false disables the tweak
42
49
  locateCurrentSession: true # default true; false hides the sidebar locate button
@@ -53,22 +60,26 @@ Settings entry: **Settings → Style tweaks**.
53
60
 
54
61
  ```bash
55
62
  # from npm (recommended, prebuilt)
56
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks
63
+ dsh plugin --profile web add dsh-style-tweaks
57
64
 
58
65
  # from GitHub (source; runs the self-contained prepare build)
59
- 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
60
67
  ```
61
68
 
62
69
  The package spec after `add` is forwarded to pnpm verbatim, so versions can be
63
70
  pinned — `@version` for the npm package, `#tag` for the GitHub source:
64
71
 
65
72
  ```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
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
68
75
  ```
69
76
 
70
77
  Restart DSH web once after installing (bundle plugins are scanned at process start).
71
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
+
72
83
  ## Development
73
84
 
74
85
  ### Build
@@ -79,32 +90,196 @@ pnpm build # tsc (server) + tsc (client) + bundle lib/client.js
79
90
  pnpm typecheck
80
91
  ```
81
92
 
82
- 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):
83
115
 
84
116
  ```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
117
+ dsh plugin --profile web add "link:./"
87
118
  ```
88
119
 
89
- ### 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.
90
127
 
91
- 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:**
92
169
 
93
170
  1. **Build**:
171
+
94
172
  ```bash
95
173
  pnpm build
96
174
  ```
97
175
 
98
- 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
+
99
241
  ```bash
100
- cp lib/client.js ~/.dsh/profiles/web/node_modules/dsh-style-tweaks/lib/client.js
242
+ pnpm build
101
243
  ```
102
244
 
103
- 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**
104
249
 
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.
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
+ ```
106
279
 
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.
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.
108
283
 
109
284
  ## How it works
110
285
 
@@ -112,10 +287,11 @@ Only client plugins (`client.js`) support hot reloading. Changes to the `apps/we
112
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).
113
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.
114
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/`.
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).
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.
116
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.
117
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`.
118
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.
119
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.
120
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.
121
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.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # dsh-style-tweaks
2
2
 
3
+ 简体中文 | [English](README.en.md)
4
+
3
5
  > 依赖版本:deepseek-harness v0.1.5-rc.1
4
6
 
5
7
  [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)Web UI 插件:为 DSH 界面提供一套可选的样式调整——精确的对话列宽控制,以及侧边栏与设置面板的一系列小幅度修复。
@@ -12,13 +14,15 @@
12
14
  - **对话框宽度**:600–1600 px 之间任意值;含 748(默认)/ 880(稍宽)/ 1024(更宽)三个预设按钮。仅在"插件宽度控制"开启时显示。
13
15
  - **两侧边距**:插件宽度控制开启时,对话区域两侧保留的空白(px),列宽被钳制为对话框宽度,侧边栏打开或窗口缩小时内容会收窄,不会贴住边缘。最低 32 px。仅在"插件宽度控制"开启时显示;关闭后边距不生效,保持 DSH 原生行为。
14
16
  - **思考内容固定高度**(默认关闭):展开"深度思考"正文时限制为固定高度(默认 300 px,120–1200 可调),超出部分在窗口内滚动,很长的思考不再把后面的回复顶出视野;思考仍在生成时窗口显示开头,可滚动查看后续。滚轮自然穿透:块内滚到边界或内容不满一块时,滚轮直接滚动会话正文。收起后照旧恢复为一行摘要。右侧配 4px 常显细滚动条(与设置菜单一致),正文与滚动条间留有间距。
17
+ - **右侧边栏初始宽度(默认关闭,45%)**:开启后接管右侧边栏首次打开时的宽度——仅在本次页面加载后的第一次打开时按"会话窗口宽度 × 百分比"写入一次(15–70 可选,含 30 / 40 / 45 / 55 四个预设)。之后手动拖拽、关闭再打开都保留你自己拖出的宽度;刷新页面后该百分比重新生效。默认关闭时保持 DSH 自身的 45%,插件完全不介入;开启后默认百分比同样取 45,因此不改数值时观感与原生一致。宿主自身还会把结果钳制到它的范围内(最小 300 px、最大窗口的 70%),换算结果不足 300 px 时按 300 px 显示。0.1.5 之前的宿主上本项保持惰性。
15
18
 
16
19
  ### 调整项
17
20
 
18
21
  - **表格布局稳定(默认开启)**:锁住 markdown 表格 hover 时的布局,避免周围内容发生回流("鼠标移到表格上时下方文本会跳动")。
19
22
  - **轮次导航栏稳定(默认开启)**:向上滚动到第一条消息上方的系统提示词区域时,让右侧轮次导航栏保持在原位(不再下移约 16 像素)。
23
+ - **轮次导航常显(默认关闭)**:DSH 会在聊天列内容盒宽度降到 900 px 时隐藏右侧轮次导航栏(`TurnNavigator.module.css` 中针对聊天滚动区的容器查询);把右侧边栏拉宽正是触发条件——中间列最多可被压到 400 px,因此右栏在其几乎整个可用区间里都看不到轮次导航。开启后任何聊天宽度下都保留轮次导航;窄宽度下导航停在滚动区右侧的空隙里,悬停预览会遮住部分正文。
20
24
  - **代码块顶部贴齐(默认开启)**:去掉高亮代码块上方的 16 px 空白,让代码块紧贴在前面的段落、列表项或标题下方。
21
- - **项目目录运行指示(默认开启)**:在侧边栏项目目录右侧显示与对话标题一致的运行动画圆点,目录收起时也能一眼看出里面有对话正在进行。
25
+ - **项目目录运行指示(默认开启)**:把与对话标题一致的运行动画圆点放进侧边栏。**项目目录右侧**显示一个,目录收起时也能一眼看出里面有对话正在进行;**会话行的状态槽**也会补一个——会话有后台任务在跑、原生圆点不显示时正好补上(原生自己显示了圆点时本项让位,不重复)。判定"忙"的三个来源:会话正在执行一轮对话、会话有后台任务在跑(`run_in_background` 的 shell 等,从启动一直亮到任务结束)、它下面有子代理在跑。
22
26
  - **定位当前会话(默认开启)**:在侧边栏"工作区"段头部的搜索按钮左边新增一个定位按钮;点击后自动展开当前会话所属的工作区目录(包括"展开其余 x 个会话"的折叠层),将该会话滚动到侧边栏视口中央。无当前会话时按钮禁用并提示"请先打开一个会话"。
23
27
  - **设置菜单可滚动(默认开启)**:设置项较多时,让设置面板左侧菜单可以上下滚动,而不是把放不下的项直接裁掉(面板高度固定且 `overflow: hidden`,原生样式只给右侧内容列加了滚动)。滚动条为悬浮式细条:停靠在菜单右侧的留白里、不挤压菜单宽度,只在列表实际滚动时出现,停止滚动约 0.8 秒后淡出。
24
28
  - **中键关闭侧边栏标签页(默认开启)**:0.1.5 新增的右侧边栏是标签页面板。开启后,鼠标中键点击任一标签页即关闭它(浮动面板的标签页同样适用),与浏览器标签页的习惯一致。关闭走宿主公开的 `ctx.sidebarRight` 关闭接口,原生规则照常生效:唯一驻留的引导页不可关、关闭最后一个停靠标签时侧边栏一并收起,与 ✕ 按钮的行为完全一致;条上的中键自动滚动被抑制,中键按下也不再启动标签拖拽,按住中键移动不会误把标签拖走或浮出。0.1.5 之前的宿主上本项保持惰性。
@@ -34,9 +38,12 @@ style-tweaks:
34
38
  sideMargin: 50 # ≥ 32 px;仅 usePluginWidth 开启时显示/生效
35
39
  thinkFixedHeight: false # 默认 false;true 则"深度思考"正文限高滚动
36
40
  thinkHeight: 300 # 120–1200 px;仅 thinkFixedHeight 开启时显示/生效
41
+ rightbarInitialWidth: false # 默认 false;true 则由插件按下方百分比接管右栏首开宽度
42
+ rightbarWidthPercent: 45 # 15–70;右侧边栏首次打开时占会话窗口宽度的百分比
37
43
  # 调整项
38
44
  stableTable: true # 默认 true;false 则关闭
39
45
  stableTurnRail: true # 默认 true;false 则关闭
46
+ keepTurnRail: false # 默认 false;true 则任何聊天宽度下都保留轮次导航
40
47
  codeBlockFlushTop: true # 默认 true;false 则关闭
41
48
  projectRunningIndicator: true # 默认 true;false 则关闭
42
49
  locateCurrentSession: true # 默认 true;false 则隐藏侧边栏定位按钮
@@ -53,21 +60,24 @@ style-tweaks:
53
60
 
54
61
  ```bash
55
62
  # 方式一:从 npm 安装(推荐,预构建产物)
56
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks
63
+ dsh plugin --profile web add dsh-style-tweaks
57
64
 
58
65
  # 方式二:从 GitHub 仓库安装(源码,会运行自包含的 prepare 构建)
59
- 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
60
67
  ```
61
68
 
62
69
  `add` 后面的包说明会**原样转发给 pnpm**,因此可以指定版本——npm 包用 `@版本号`,GitHub 源码用 `#tag`:
63
70
 
64
71
  ```bash
65
- npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks@0.1.0 # 锁定 npm 版本
66
- npx -y @deepseek-ai/dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.0 # 锁定 git tag
72
+ dsh plugin --profile web add dsh-style-tweaks@0.1.3 # 锁定 npm 版本
73
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks#v0.1.3 # 锁定 git tag
67
74
  ```
68
75
 
69
76
  安装完成后**重启一次 `dsh web`**(bundle 插件在进程启动时扫描)。
70
77
 
78
+ > 上面假定 `dsh` 已在 PATH 上。没有全局安装时,把每条命令开头的 `dsh` 换成
79
+ > `npx -y @deepseek-ai/dsh` 即可(例如 `npx -y @deepseek-ai/dsh plugin --profile web add dsh-style-tweaks`)。
80
+
71
81
  ## 开发
72
82
 
73
83
  ### 构建
@@ -78,32 +88,178 @@ pnpm build # tsc(服务端)+ tsc(客户端)+ 打包 lib/client.
78
88
  pnpm typecheck
79
89
  ```
80
90
 
81
- 本地加载(覆盖层)或作为 bundle 安装:
91
+ 本地加载(覆盖层)或软链安装:
92
+
93
+ ```bash
94
+ dsh web --patch ./cordis.patch.yml # 开发覆盖层
95
+ dsh plugin --profile web add "link:./" # 软链安装(本地开发,见下)
96
+ ```
97
+
98
+ ### 本地开发(推荐):`link:` 软链 + 热加载
99
+
100
+ 开发时**不要把仓库打包成 `.tgz` 再装进 profile**,也不需要在每次构建后手动复制文件。用 pnpm 的
101
+ `link:` 协议把 profile 里的 `node_modules/dsh-style-tweaks` 直接软链到本仓库,构建产物就落在被
102
+ DSH 加载的那个路径上——改完代码不需要复制、不需要重新打包、不需要重装插件。
103
+
104
+ 下文用 `<profile>` 代指 profile 目录:
105
+
106
+ | 系统 | profile 目录 |
107
+ |---|---|
108
+ | macOS / Linux | `~/.dsh/profiles/web` |
109
+ | Windows | `%USERPROFILE%\.dsh\profiles\web` |
110
+
111
+ **一次性安装**(在仓库根目录执行,`add` 后面的包说明会原样转发给 pnpm):
112
+
113
+ ```bash
114
+ dsh plugin --profile web add "link:./"
115
+ ```
116
+
117
+ 这一步会同时写入 profile 的 `dependencies` 与 `dsh.profile.bundles`,并让 pnpm 建好软链;
118
+ `link:./` 最终被记成相对路径还是绝对路径取决于 CLI 实现,装完按下面的命令验证一下即可。
119
+
120
+ > **建软链的前提**:Windows 需要**开发者模式**(设置 → 系统 → 开发者选项)或管理员权限,
121
+ > 否则 pnpm 可能建不出符号链接(个别版本会退回 junction,功能上等效);macOS / Linux 无此限制。
122
+
123
+ > **只有当你曾手动禁用过这个插件时才需要这一步**:DSH 的树按「`dsh.profile.bundles` →
124
+ > `cordis.patch.yml` → `--patch` 覆盖层」依次合成,patch 层最后应用,而 `dsh plugin add/remove`
125
+ > 只维护 `dependencies` 与 `dsh.profile.bundles`、不会动这份手写文件。因此 patch 层里若留着
126
+ > `disabled: true`,它会活过 remove → add,表现为「装上了、也构建了,但页面什么都不挂」。
127
+ >
128
+ > 要删的是 **profile 的那份** `<profile>/cordis.patch.yml`,**不是仓库根目录的同名文件**
129
+ > (那份是本 bundle 自带的补丁,只有一条 `insert`,不含 `disabled`)。先确认有没有:
130
+
131
+ ```bash
132
+ grep -n "style-tweaks" ~/.dsh/profiles/web/cordis.patch.yml # macOS / Linux
133
+ ```
134
+
135
+ ```powershell
136
+ Select-String "$env:USERPROFILE\.dsh\profiles\web\cordis.patch.yml" -Pattern style-tweaks
137
+ ```
138
+
139
+ > 只删这一条,其他条目的 disable 保留(那些是刻意的)。
140
+
141
+ 验证软链确实指向本仓库:
142
+
143
+ **macOS / Linux(含 WSL、Git Bash)**
144
+
145
+ ```bash
146
+ link=~/.dsh/profiles/web/node_modules/dsh-style-tweaks
147
+ ls -ld "$link" # 期望 l 开头,且 -> 指向本仓库
148
+ readlink "$link" # 期望 = 本仓库根目录
149
+ test -e "$link/src" && echo "src 可见,确实指到源码"
150
+ ```
151
+
152
+ **Windows(PowerShell)**
153
+
154
+ ```powershell
155
+ $link = "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-style-tweaks"
156
+ (Get-Item $link).LinkType # 期望 SymbolicLink(或 Junction)
157
+ (Get-Item $link).Target # 期望 = 本仓库根目录
158
+ Test-Path "$link\src" # 期望 True,证明确实指到源码
159
+ ```
160
+
161
+ **每次改完代码的热加载流程**:
162
+
163
+ 1. **构建**:
164
+
165
+ ```bash
166
+ pnpm build
167
+ ```
168
+
169
+ 这一步不能省。仓库里**没有** `dev` / `watch` 脚本,没有任何东西会替你监听 `src/` 并重新编译;
170
+ 改完源码必须手动构建,否则页面加载的仍是旧 bundle(表现为「改了没反应」)。软链下
171
+ `pnpm build` 会原地重写 `lib/client.js`——也就是 DSH 实际加载的那份文件,
172
+ **不再需要任何复制到 profile 的步骤**。
173
+
174
+ 2. **让页面重新加载 bundle**:
175
+
176
+ - **先硬刷新 GUI 页面**:Windows / Linux 为 `Ctrl+Shift+R`,macOS 为 `Cmd+Shift+R`
177
+ (或关掉标签页重开)。这是实测最常用、最可靠的一步,绝大多数情况刷新就够了。
178
+ - 只有刷新后依然不生效时,才重启 `dsh web` 进程(停掉再重新运行即可)。
179
+
180
+ README 旧版本写的「client-plugin HMR receiver 会检测变更并自动重载」在本地**实测经常不触发**:
181
+ 本地 `dsh web` 通常从安装包启动,而不是从源码 checkout 跑 `dev:web`,页面会一直用已经取到的
182
+ 那份 bundle。所以「改完刷新前毫无变化」是正常现象,不代表代码没生效。软链只保证**磁盘上的文件
183
+ 是新的**,不保证**页面主动重新取**。
184
+
185
+ **怎么确认新代码真的加载了**:
186
+
187
+ - 页面里应当存在 `style[data-tweak-css="cst-*"]` 之类的注入样式,以及各调整项留在 `window` 上的
188
+ `__cst_*_cleanup__` 守卫(例如 `__cst_project_running_indicator_cleanup__`)。守卫是 `undefined`
189
+ 说明该调整项没挂上。
190
+ - 想知道页面拿到的是哪一版 bundle:从 `performance.getEntriesByType('resource')` 里取
191
+ `/plugins/??…dsh-style-tweaks…` 那条的完整 URL,再 `fetch(url, { cache: 'no-store' })`,
192
+ 检查里面有没有你刚加进去的标识符。构建后页面**不会**主动重新请求这个 URL,这本身也是判断
193
+ 「HMR 有没有生效」的直接手段。
194
+ - 若刚好在 DSH 读取 bundle 时覆盖了 `lib/client.js`,可能出现一次「插件整个没挂载」的假象
195
+ (守卫和样式都不在)。刷新一次即可恢复,先别急着怀疑代码。
196
+
197
+ **边界**:只有客户端产物 `lib/client.js` 能这样热加载。服务端代码
198
+ (`src/index.ts` / `src/web.ts` / `src/config.ts` → `lib/index.js`)除了 `pnpm build`,
199
+ 还必须重启 `dsh web`;涉及 `apps/web` shell 或普通 package 的改动,还要重建对应的 Web 产物并刷新页面。
200
+
201
+ **回到发布版本**(用包名,不含任何本地路径):
202
+
203
+ ```bash
204
+ dsh plugin --profile web remove dsh-style-tweaks
205
+ dsh plugin --profile web add github:zhj9709/dsh-style-tweaks
206
+ ```
207
+
208
+ ### 备选:打包成 `.tgz` 安装(需要复制产物)
209
+
210
+ 不想让 profile 依赖本仓库路径、或想完整走一遍真实安装流程时,可以把仓库打包成 `.tgz` 再装进 profile。
211
+ 代价是 profile 里的 `node_modules/dsh-style-tweaks` 变成**一份真实拷贝**(不再指向源码),
212
+ 因此改完源码必须把构建产物复制过去——也就是 `link:` 方案帮你省掉的那一步。
82
213
 
83
214
  ```bash
84
- npx -y @deepseek-ai/dsh web --patch ./cordis.patch.yml # 开发覆盖层
85
- npx -y @deepseek-ai/dsh plugin --profile web add . # 从本目录作为 bundle 安装
215
+ pnpm build
216
+ pnpm pack # 产出 dsh-style-tweaks-<版本>.tgz(在 .gitignore 里,不要提交)
217
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.3.tgz
86
218
  ```
87
219
 
88
- ### 热重载(免安装)
220
+ 装完**重启一次 `dsh web`**(bundle 插件在进程启动时扫描)。
89
221
 
90
- 构建产物只有客户端插件(`lib/client.js`)需要被 DSH 运行时加载。每次修改代码后:
222
+ 之后每次改代码:
91
223
 
92
224
  1. **构建**:
225
+
93
226
  ```bash
94
227
  pnpm build
95
228
  ```
96
229
 
97
- 2. **复制到 profile 目录**:
230
+ 2. **把产物复制进 profile**(`link:` 方案下不需要这一步)。用「先删后拷」而不是直接覆盖:
231
+
232
+ **macOS / Linux**
233
+
98
234
  ```bash
99
- cp lib/client.js ~/.dsh/profiles/web/node_modules/dsh-style-tweaks/lib/client.js
235
+ dest=~/.dsh/profiles/web/node_modules/dsh-style-tweaks/lib/client.js
236
+ rm -f "$dest" && cp lib/client.js "$dest"
100
237
  ```
101
238
 
102
- 3. **DSH 的 client-plugin HMR receiver** 会检测文件变更并自动重新加载插件,无需重新安装。
239
+ **Windows(PowerShell)**
103
240
 
104
- > 注意:有时 GUI 进程会缓存旧 bundle,表现为改动未生效。此时需要重启 `dsh web` 进程。
241
+ ```powershell
242
+ $dest = "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-style-tweaks\lib\client.js"
243
+ Remove-Item $dest -Force
244
+ Copy-Item lib/client.js $dest
245
+ ```
246
+
247
+ 3. **硬刷新 GUI 页面**;依旧不生效时重启 `dsh web`。
248
+
249
+ > **为什么不能直接 `cp -f` 覆盖**:pnpm 默认把 `node_modules` 里的文件链接到自己的内容寻址 store,
250
+ > Linux / Windows 上用的是**硬链接**,原地覆盖会穿透写回 store,可能污染其他用到同一份文件的项目。
251
+ > macOS 的 APFS 默认走 clonefile(写时复制),通常不受影响,但「先删后拷」在所有平台都安全。
252
+ > GNU coreutils 下也可以写 `cp --remove-destination`,注意 **macOS 的 BSD `cp` 没有这个选项**。
253
+
254
+ 从 `link:` 切回 `.tgz` 要先 `remove` 再 `add`,因为 profile 里已有一条本地路径依赖:
255
+
256
+ ```bash
257
+ dsh plugin --profile web remove dsh-style-tweaks
258
+ dsh plugin --profile web add ./dsh-style-tweaks-0.1.3.tgz
259
+ ```
105
260
 
106
- 只有客户端插件(`client.js`)支持热重载。修改 `apps/web` shell 或普通 package 后仍需重新构建 Web 产物并刷新页面。
261
+ 两个方案的差别只有「产物是否自动落到位」这一条:都要 `pnpm build`,都要刷新页面。
262
+ 日常改代码用 `link:`,需要验证发布包本身时才用 `.tgz`。
107
263
 
108
264
  ## 工作原理
109
265
 
@@ -111,10 +267,11 @@ npx -y @deepseek-ai/dsh plugin --profile web add . # 从本目录作为 b
111
267
  - **浏览器端**(`src/client/index.tsx`):读写该路由、渲染设置页,并根据每个开关的状态实时挂载 / 卸载对应的调整项(纯 CSS 调整项注入运行时 `<style>` 元素;JS 级调整项还会读写应用自身的状态 store 并修补 DOM)。
112
268
  - **列宽样式引擎**(`src/client/conversation-width.ts`):写入 `--dsh-chat-user-width` CSS 变量,并在插件接管列宽时隐藏原生 `[data-width-handle]` 拖拽手柄;宽度值同时镜像到原生手柄读取的 localStorage 槽位,开关切换时无缝往返。
113
269
  - **调整项注册表**(`src/client/tweaks/registry.ts`):每个调整项的元数据(id、settings 字段名、默认值、i18n 键)集中登记;新增调整项只需在注册表里加一条,并在 `src/client/tweaks/` 下新增一个注入文件。
114
- - **项目目录运行指示**(`src/client/tweaks/project-running-indicator.ts`):从 `ctx.get('sessions')` / `ctx.get('workspaces')` 读取会话运行状态与目录归属,用 MutationObserver 在项目目录头行(`role="treeitem"[aria-expanded]`,稳定手写属性)内挂载应用自身的 `StateDot`(复用 `@deepseek-ai/dsh-client-ui-primitives` 的同一份模块,动画 keyframes 与样式 token 与对话标题处完全一致)。
270
+ - **项目目录运行指示**(`src/client/tweaks/project-running-indicator.ts`):从 `ctx.get('sessions')` / `ctx.get('workspaces')` 读取会话运行状态与目录归属,用 MutationObserver 在侧边栏的两类行上挂载应用自身的 `StateDot`(复用 `@deepseek-ai/dsh-client-ui-primitives` 的同一份模块,动画 keyframes 与样式 token 与对话标题处完全一致):项目目录头行(`role="treeitem"[aria-expanded]`)右侧,以及会话行(`role="treeitem"` + CSS Modules 局部名 `sessionRow`)原本空着的状态槽里。"忙"的判定合并三个来源:会话自己的 `SessionSummary.running`(正在跑一轮);会话名下的后台任务——`sessions` store 快照里由 Session Controller 控制流镜像出来的 `jobsBySession`,状态为 `running` / `stopping` 即算(与 DSH 原生会话头部"后台任务"入口 `ui-jobs` 的 `isLive` 同一判据,`run_in_background` 的命令因此从启动亮到结束,不会因为发起它的那一轮已经答完而熄灭);以及血缘上有子代理在跑(与 `indexSubagentDescendants` 同一条上行遍历)。目录头行按标题文本匹配工作区、原封不动照旧;会话行只在它那一格**空着**时补点——原生已经渲染了自己的圆点(对话进行中、子代理在跑、等待审批/回答、完成未读)就让位,避免两个点并排。会话行没有可用的插件槽位,DOM 上也不带 session id,因此 id 从 React fiber 链上读(`SessionNodeItem` 的 `props.node.id`):读取全程防御式,读不到就不处理该行,且每一轮都重新解析,因为侧栏的行会被回收复用。宿主没有 `jobs` 服务时 `jobsBySession` 缺失或为空,自动退回只看原生覆盖的两种情况。
115
271
  - **定位当前会话**(`src/client/tweaks/locate-current-session.ts`):在 DSH 侧边栏"工作区"段头部的搜索按钮左边注入一个新按钮。锚点全部走 i18n 与语义片段:通过 `ctx.locale.bind()` 解析当前语言的搜索按钮 aria-label(`workspace` 命名空间的 `search.sessions.aria` 键)与顶部面包屑的 aria-label(`conversation` 命名空间的 `session.hierarchy` 键),locale 服务不可用时退回 `[class*="searchButton"]` / `[class*="crumbs"]` 语义片段兜底。点击后从面包屑(`button[disabled]` 的当前项)读出会话标题,用 `ctx.get('sessions')` 与 `ctx.get('workspaces')` 两个应用级 store 反查所在工作区标题(即使其工作区目录收起也能定位),再按标题匹配工作区行;依次穿透两级折叠——工作区收起时 click 展开、"展开其余 x 个会话"溢出按钮(`[class*="sessionOverflowButton"][aria-expanded="false"]`)挡住目标行时自动点开——然后 `scrollIntoView({ block: 'center' })` 滚动居中。悬停提示复刻了 DSH 原生 `<Tooltip>` 的自研气泡(fixed 定位 + 主题 token + 500ms 延迟 + 视口翻转),而非浏览器原生 `title`。MutationObserver 监听 `aria-selected` / `aria-expanded` 属性变化同步按钮可用性与挂载状态。
116
272
  - **设置菜单可滚动**(`src/client/tweaks/settings-nav-scroll.ts`):DSH 设置面板(`SettingsRoot`)高度固定且 `overflow: hidden`,原生样式只让右侧内容列(`.options`)滚动;左侧菜单列表 `.navList` 没有 `min-height: 0` 与 overflow 处理,条目多时被面板直接裁掉。本调整项用"两条 CSS 规则 + 一个小型 JS 驱动"实现:基础规则 `flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: 12px; margin-inline-end: -12px` 让列表成为滚动容器,并把滚动条"停靠"进导航栏右侧的 12px 留白里(8px 间距 + 4px 条位,恰好占满留白),滑块悬浮在留白上、与菜单项保持 8px 间距;菜单项上限 `max-width: 164px`(DSH 原生 188 导航 − 2×12 padding)钉住原生宽度,无论滚动条出现与否、悬停还是滚动,菜单项都与原生完全同宽;`::-webkit-scrollbar { width: 4px }` 比全局皮肤细两档。显示时机由 JS 驱动:第一版用 `:not(:hover)` 隐藏滑块,但 Chromium 在宿主 `:hover` 变化时不会可靠重绘自定义滚动条伪元素(实测表现为"点击菜单才出现"),因此改为监听列表 `scroll` 事件——滚动时给列表加 `cst-nav-scroll-show` 类显示滑块(颜色沿用面板继承的 l2 主题 token),停止滚动约 0.8 秒后移除类淡出;MutationObserver 在设置弹窗卸载/重开时保持监听器挂在当前列表上。选择器用"弹窗结构 + CSS Modules 语义片段"双保险(DSH 类名按 `[hash]_[local]` 哈希,`navList` 局部名全 DSH 唯一),无需 `!important`。
117
273
  - **中键关闭侧边栏标签页**(`src/client/tweaks/sidebar-middle-click-close.ts`):右侧边栏的标签芯片是 `div[role="tab"][data-dockkit-tab="<tabId>"]`,由 `ui-dockkit` 的 `TabPanel` 渲染,而 dockkit 只有右侧边栏(停靠面板与浮动层)采用,因此"芯片"即"侧边栏标签页"。本项在 `document` 捕获阶段挂三个监听(React 17+ 把监听挂在根容器上,document 捕获先于整棵 React 树,拦截因此彻底):`pointerdown`(中键)拦截传播,芯片自带的按压→拖拽手势无从启动,按住中键移动 ≥4px 也不会再把标签拖走、分屏或浮出;`mousedown`(中键)`preventDefault`,抑制浏览器在标签条上的自动滚动(不影响其后仍会派发的 `auxclick`);`auxclick`(中键,浏览器对非主键点击只派发 `auxclick`、不派发 `click`)读出芯片上的 `TabId` 交给 `ctx.sidebarRight.close()`——关闭完全走宿主 store 自己的规划规则(缺失标签无事发生、唯一驻留引导页不可关、关闭最后一个停靠标签时侧边栏收起),插件不复制任何一条。`ctx.sidebarRight` 是 0.1.5+ 宿主才提供的服务,且由 `ui-sidebar-right` 在**它自己的插件 fiber** 里 provide——cordis 的属性访问只沿祖先 fiber 链解析,从兄弟插件读会直接抛错("cannot get property … without inject"),而把它加进本插件的 `inject` 会让服务变成必需、在 0.1.2 宿主上卡死启动;因此该 face 在每次中键关闭时经 `ctx.reflect.get('sidebarRight')` 解析——reflect 实例与 store 为根共享、provide 的隔离键也登记在根,`get` 无需 inject 声明即可跨 fiber 读到兄弟提供的服务,未提供时返回 `undefined` 而非抛错(0.1.2 宿主 → `undefined`,本项保持惰性,仅保留副作用抑制);`close()` 在无挂载 seat 时抛错,统一吞掉。浮动面板的头部不渲染芯片,其中键关闭转发给头部自带的 ✕ 按钮(`canCloseTab` 不允许时按钮不存在,无事发生),头部的"停靠回面板"按钮被显式排除;按压与释放不在同一芯片上时,浏览器把 `auxclick` 派发到两者的公共祖先,命中不了任何芯片,同样不会关闭。
274
+ - **右侧边栏初始宽度**(`src/client/tweaks/rightbar-initial-width.ts`):右侧边栏的宽度由宿主 `ui-layout` 决定——其 `openRightbar` action 在首次打开时用 `rightbar ??= max(300, round(viewportWidth × 0.45))` 填入 45% 的 px 值(常量 `RIGHTBAR_DEFAULT_RATIO`),拖拽经 `setRightbar` 覆盖(钳制到 `[300, 窗口 × 70%]`),其后关闭 / 再打开都保留该 px;宽度偏好只存在内存 store 里,刷新即回到 `null`。`ctx.layout` 只暴露 `openRightbar(track, fullscreen)` / `closeRightbar()` / `toggleSidebar()` / `selectPanel()`,改宽度的 `setRightbar` 挂在控制器自己的 `panels` 动作集上(TS 层是 `private`,运行时是普通字段)。本项因此包装控制器**实例**的 `openRightbar`:本次页面加载的第一次打开时,先按 `[data-rightbar-col]` 的父元素(宿主测量 `viewportWidth` 的同一个 frame 元素)实测宽度算出 `round(宽 × 百分比)`,经宿主自己的 `panels.setRightbar` 写入,再转发原调用——写入与打开落在同一次 React 提交里,不会先闪一帧 45%;全程不碰 grid 模板与面板 inline 宽度,因此列解算、拖拽手柄位置与面板宽度天然一致。两个页面级标记守住语义:`__cst_rightbar_initial_width_seeded__` 保证每次加载只写一次;`__cst_rightbar_initial_width_dragged__` 由挂在 `[data-side="rightbar"]`(宿主右列拖拽手柄)上的**一次性**探针在首次 `pointerdown` 时置位——用户一旦亲手拖过,本次加载内本项彻底不再写入,否则每次设置变更触发的整体重挂载都会覆盖刚拖出来的宽度。右栏在本项挂载时已打开(用户在设置面板改百分比)则立即按新比例写入,即所见即得的实时预览。宿主未提供 `layout` 服务、或控制器不再携带可调用的 `setRightbar` 时保持惰性;卸载只在当前值仍是自己的包装时才还原宿主方法,不踩其它插件的包装。
118
275
  - **经典统计行**(`src/client/tweaks/legacy-stats-line.tsx`):0.1.5 起 DSH 把输入框下方的统计行(旧 `StatsLine`)换成了图标胶囊(新 `StatsPills`),两者挂载方式相同——`conversation.composer.dock` 列表槽位上 id 为 `stats` 的条目。列表槽位的规则是同一 id(同一"格")里 priority 最低的存活条目渲染,因此本调整项在开关打开时用 `id: 'stats'` + `priority: -2` 影子覆盖原生条目(走槽位系统自己的遮蔽机制,无需 CSS 隐藏或 DOM 修补;它比"缓存命中两位小数"的 `priority: -1` 更优先,见下条),销毁注册即把格子还给原生胶囊;若条目渲染崩溃会自动退位,胶囊原样回来。数据面与新胶囊一致:读 `sessionStats`(整段日志的轮数/步数/耗时)与 `tokenUsage`(计费桶)两个持久化投影,不再做旧版"可见窗口折叠"的兜底(无投影时整行不渲染,符合旧行"无数据不出行"的规则)。文案走插件自己的 `style-tweaks` 命名空间(`legacyStats.*` 键)——旧版 `stats.llm` 系列键在 0.1.5 的词典里已被删除,插件自带中英文案;行样式(居中、三级文字色、省略号截断)按 0.1.2 的 `StatsLine.module.css` 移植,token 全部沿用原值并带兜底。行根元素带 `data-composer-stats` 标记:宿主 InputBar 会围绕任何挂载的统计行把自己的 8px 底距收紧到 4px(`.root:has([data-composer-stats])`),行本身底部补 2px 使总高与胶囊行(26px 行 + 4px 宿主底距)一致——开关切换时对话内容不再上下位移。缓存命中率的小数位数跟随"缓存命中两位小数"开关:开启时按两位小数,关闭时按 0.1.2 原样取整;该开关在挂载时捕获,任何设置变化都会重挂载全部调整项,因此始终即时生效。
119
276
  - **缓存命中两位小数**(`src/client/tweaks/pills-cache-hit-decimals.tsx`):新胶囊的命中率取整发生在 dsh-client-ui-chat 模块内部的格式化函数里,插件无法触及,因此本调整项在开关打开时对同一个 `stats` 槽位格做影子替换(`priority: -1`),由插件自绘整行——两个胶囊与点开弹窗按 0.1.5 的 `StatsPills` / `stat-dialog` 逐样式移植(定位与外点关闭直接复用宿主 primitives 的 `useAnchoredPosition` / `useDismissOnOutsidePointer`),缓存命中率改走插件共享的 `formatCacheHitPercent(…, 2)`(按两位小数取整,靠近 100% 时仍按"诚实尾数"多显示一位,如 `99.97`),弹窗各行与胶囊标签同步生效。数据面同样是 `sessionStats` / `tokenUsage` 两个投影(不做窗口折叠兜底);根元素同样带 `data-composer-stats`。图标方面,胶囊的 gauge 图标在 0.1.3+ 的 primitives 里才存在,故运行时从宿主 primitives 取 `IconGaugeOutline16`,老版本宿主取不到则回退为时钟图标。与"经典统计行"的层叠关系:两项同写一个槽位格,经典统计行注册在 `priority: -2` 优先渲染,本项在其下被遮蔽(不渲染、零开销);经典统计行关闭后本项若仍为开则自动接管格子,两者都关时恢复原生胶囊。该开关始终显示,不再随经典统计行隐藏:经典统计行同样读取它决定命中率的小数位数。
120
277
  - **轮次速度与首 token 用时**(`src/client/tweaks/turn-speed-metrics.tsx` + `src/client/tweaks/assistant-stream-timing.ts`):会话格式 v2(0.1.5)把每次模型尝试的精确时序流嵌进持久化结算(`assistant/message` 的 `data.stream`),但 Chat 界面的冷呈现直接从组装后的消息构建、不回放嵌入流(v2 架构笔记原文:"Cold settled presentation therefore does not reconstruct per-token timing")——轮次尾部的折叠读节点内存 timing,其 `firstTokenTime` 只在 live-chunk 线性折叠中存活,重载后每轮的输出速度(TPS)与首 token 用时(TTFT)全部消失,"本轮用时和速度"弹窗只剩墙钟总用时。本调整项就是该笔记预期的"需要精确证据的消费者":从会话绑定的公开事件窗口(`ctx.sessions.binding(sessionId).eventSource`,Conversation 组装消费的同一份 feed)读出结算,把 dsh-llm 的紧凑流读取器(`assistantStreamFirstTokenTime`:按 `time0` + `dt` 间隔重建打包 run 内首个 token 的时间)与 0.1.5 `deriveTurnMetrics` 的折叠一并移植——TTFT 取最低 step 的"派发→首 token"延迟,TPS = 携带时序与用量的 step 的 Σ输出 token ÷ Σ解码墙钟。数据全部来自持久化日志,历史会话与刚完成的轮次拿到相同数值。展示选择:弹窗属于 `TurnTimePanel`,无插槽可用,因此本项在 `conversation.chat.assistant-actions` 列表槽(每轮操作行内;list 槽可加性渲染,与其它插件条目无选举冲突)挂一个不可见的控制器——页脚捕获监听只把被点击的页脚转交给一个共享的 body 级 `MutationObserver`(弹窗 portal 到 `<body>`,面板节点无法经 `closest` 找回页脚);观察器回调是微任务,必然先于下一帧渲染,按 `data-turn-time-details` 稳定标记认领刚提交的弹窗,在“本轮总用时”行后追加 `<dt>/<dd>` 两行:标签直接用宿主词典里仍存活的 `message.turnTime.speed` / `message.turnTime.ttft` / `message.tokensPerSecond` / `duration.seconds` 键,数值行自动继承弹窗自己的 `.details dt/.details dd` 网格样式,与 0.1.2 弹窗逐字一致。追加行会让面板变高,而 `useAnchoredPosition` 的重定位经 `ResizeObserver`→`setState` 延迟到下一帧之后,首帧会先画在偏低 48 px 的旧 `top` 再跳上去(正是“闪一下”)——回填在同一个微任务里按新增高度把固定定位卡片同步上移:弹窗是 `side: 'top'` 挂在胶囊上沿,保持卡片底边不动正是宿主 `place()` 对更高面板算出的同一个值,因此首个绘制帧即最终位置,宿主随后的写入变成同值空操作;只有位移会越过视口顶边(被钳制的极端摆放)才跳过。曾试过“先隐藏卡片、等宿主重定位写入再显示”,它把原本正确的即时打开变成可见的延迟弹出(原本不闪的会话反而开始闪),已弃用;也不要按页脚几何反推位移方向,因为 `data-turn-tail` 是整个轮次的大容器而非操作行;若该轮的线性时序本就存活(原生已渲染速度/TTFT 行),按标签去重跳过。行是注入 React 弹窗内的普通 DOM,随弹窗开合生灭,重开即重注;无数据(流内无 token 或结算不在窗口内)不注入任何行,开关关闭即恢复原生行为。