# dsh-chat-diff-summary-legacy A Codex-style per-turn change summary bar above the composer, built for the **legacy** line of the DeepSeek Harness. ``` [ 1 个文件已更改 +542 -20 ] ``` - **Kernel range: `>=0.1.5-rc.1 <0.1.6`**, verified on `0.1.5-rc.2` — the kernel DSH Desktop 2.0.13 ships. See [Compatibility](#compatibility). - Changed-file count in ordinary text; `+added` in the success token and `-deleted` in the error token; one 38px row, 14px radius, 1px border, no heavy shadow, no gradient, no glass effect. - Renders nothing at all when a turn changed nothing. - Switching Sessions swaps the numbers; two Sessions never share a summary. ## Preview Captured from the renderer of a real DSH Desktop 2.0.13 / DSH 0.1.5-rc.2 running `@deepseek-ai/dsh-base` + `@deepseek-ai/dsh-web-app` + this plugin, in an isolated `DSH_HOME`. The nine changed files were written to disk while a real turn was open, so the turn's own start-to-end measurement produced the numbers shown — and `git diff --numstat` over the same baseline reproduces them exactly: | | | |---|---| | `docs/preview/01-collapsed-dark.png` | the summary row, dark theme | | `docs/preview/02-expanded-dark.png` | expanded: the file list unfolds above the row | | `docs/preview/03-collapsed-light.png` | the summary row, light theme | | `docs/preview/04-expanded-light.png` | expanded, light theme | The expanded pair also shows the overlay behaviour: the reply behind the panel is partly covered by it rather than pushed up. 中文说明见 [README.md](README.md). Both themes are the host's own tokens rendered by the host's own theme layer — the plugin contributes no colour of its own. ## Layout - The summary row **shrinks to its own contents** — a compact pill around `N files changed +a -b` — and is centred (`.cdsl-bar { width: max-content; max-width: 100% }`). It is not a composer-wide bar. A label too long for the composer ellipsizes; the counts stay whole. - Inside the row the label and the counts sit **one character apart** (`gap: 1em`); there is no elastic spacer pushing them to opposite edges. - The file list it opens keeps **one third** of the composer's width and is centred, so a long file name has room before it ellipsizes. - The list **pages at seven rows**: its height is exactly `7 × 38px + 2 × 6px`, so a row is never half-clipped at the fold, and the rest is reached by scrolling inside the panel. The wheel is kept inside it (`overscroll-behavior: contain`), so reaching the last file does not start scrolling the conversation behind the composer. Its scrollbar is drawn from the host's own `--dsh-scrollbar-*` tokens. - The list **floats over the conversation**: it is taken out of flow (`position: absolute; bottom: calc(100% + 2px)`) and covers whatever is behind it. Expanding must not push the transcript up or shrink the viewport above the composer, so the row stays the only thing in flow and the dock keeps its height. Measured in DSH Desktop: the row stays at 38px and its top does not move, while the panel covers 280px above it. - Being an overlay, the panel carries the host's own elevation token (`--dsw-shadow-lv3`, falling back to no shadow) so it reads as a surface over the text. The row itself stays flat. - The list widens as the composer narrows, because a third of a 420px composer could not hold a name and two counts: `50%` below 760px, full width below 520px. Measured in the host's own Chromium, in a 990px composer: ``` bar width 211px (= its content: label + 1em + counts + padding) label↔counts gap 13px (= 1em at 13px) list width 330px (= 1/3 of 990) list height 278px (= 7 × 38 + 2 × 6) rows fully shown 7 scrollable: yes (10 files in the panel) row height 38px unchanged when the panel opens — nothing is pushed panel covers 280px above the row, over the transcript ``` To change the proportions, edit `src/client/styles.ts`; the page size is the single `--cdsl-list-rows` value on `.cdsl-root`. ## Live updates during a turn The bar follows a running turn; it does not wait for the turn to end. - **Baseline** at `turn/start`, as before. - **Re-measured after every settled tool call** (`tool/result`). The measurement is the same baseline-to-work-tree diff the turn's end takes, so the numbers a running turn shows are the numbers it will finish with. - **Coalesced** two ways: one measurement is in flight at a time, and two measurements of one Session are never closer than `PROGRESS_INTERVAL_MS` (400 ms) apart. A burst of tool calls therefore costs one walk of the tree, not one per call. The turn's own end is never throttled, so a change dropped by the interval is only ever *late* — reflected at the next tool call or at `turn/end` — never missing. - **The browser re-reads on every durable event**, coalesced to one read in flight at a time, and a read aimed at the open turn waits for the measurement already under way rather than answering from the previous one. Client-only live frames (streamed chunks) are deliberately excluded, or the bar would re-read on every token of a reply. - A re-read that reports the same numbers keeps the snapshot's identity, so a running turn does not churn renders and **does not fold the file list** the user just opened. Measured on a real turn in DSH Desktop 2.0.13 whose three `bash` calls each slept six seconds — the bar appeared, then grew, strictly inside the turn: ``` t+1…6s hidden (nothing changed yet) t+7s 1 个文件已更改 +2 (turn still running) t+14s 2 个文件已更改 +3 t+20s 3 个文件已更改 +6 t+24s turn ends, numbers stand ``` ## Git semantics The numbers are a **working tree to working tree** comparison, not `git diff HEAD`: 1. At `turn/start`, the complete work tree is written as a tree object. 2. At `turn/end`, the same again. 3. The two trees are diffed with `git diff-tree -r -M -z --numstat`. Because both snapshots read the same work tree, anything the user had already staged or left dirty before the turn is present in **both** and contributes nothing. Because the second snapshot reads the work tree rather than a tool log, edits made through a shell command — `sed`, a script, a Gradle task, a Python one-liner — are captured exactly like a file-tool edit. ### How the repository is left untouched Every write goes into a private scratch directory created with `mkdtemp`: - `GIT_OBJECT_DIRECTORY` points at the scratch store, so new blobs and trees land there; the repository's own store is attached read-only through `GIT_ALTERNATE_OBJECT_DIRECTORIES`. - Each snapshot writes through its own `GIT_INDEX_FILE` inside the scratch. - The private index is seeded with `git read-tree HEAD`. It is deliberately **not** a copy of the repository's index — see below. - The plugin never runs `git add` against the project index, never commits, never touches HEAD or refs, never stashes, checks out or resets. `tests/git.spec.ts` hashes the repository's `.git/index`, `HEAD`, `for-each-ref` output and every file under `.git/objects` before and after a turn that modifies, adds, deletes, renames and binarises files, and asserts all four are byte-identical. The scratch directory is removed when the Session is disposed and when the plugin is disposed. ### One real bug worth naming Seeding the private index by **copying the repository's index** is unsound, and an earlier revision of this plugin did it. A copied index carries the repository's stat cache, and `git add` is allowed to trust a cached stat: when an edit preserves a file's byte length and the copy's own mtime lands after the file's, git skips re-hashing and writes a tree holding the *previous* content. Measured on macOS with APFS, that silently produced a stale tree in **2 of 40** same-length edits — a turn whose change went unreported. `git read-tree HEAD` populates entries with no stat data, so `git add` must hash every path; the same probe scored **0 of 40**. `tests/git.spec.ts` keeps that probe as a regression guard. ## Compatibility | | | |---|---| | **Declared** | `>=0.1.5-rc.1 <0.1.6` — `dsh.engines.dsh` in `package.json` | | **Verified** | `0.1.5-rc.2`, the kernel shipped by DSH Desktop 2.0.13 | | **Untested** | `0.1.5-rc.1` and other 0.1.5-line patches | | **Out of scope** | `0.1.6` and later | **The Desktop version and the kernel version are two different numbers.** DSH Desktop is on `2.0.x`; the DeepSeek Harness kernel is on `0.1.x`. This plugin has been exercised against exactly one pairing: **DSH Desktop 2.0.13 + kernel 0.1.5-rc.2**. To read the kernel version you actually have: ```bash # @deepseek-ai/dsh-base carries the kernel version node -p "require(process.env.HOME + '/.dsh/profiles/node_modules/@deepseek-ai/dsh-base/package.json').version" # → 0.1.5-rc.2 ``` **Why the upper bound is `0.1.6`.** Every host surface this plugin touches exists in the 0.1.5 line: the `conversation.input.dock` slot, route registration through `ctx.connection.fetch`, and the `session/event` and `tools/pre-execute` events. `0.1.6-alpha.2` and `0.1.7-rc.1` do exist, but this plugin uses nothing they introduced and has never been run against them, so the range stops at `<0.1.6`. **The field is a declaration, not a gate.** `dsh.engines.dsh` is the same field `@linxin666/*` and other third-party plugins use (inside the `dsh` object, alongside `bundle` and `client`); the current DSH loading path does not read it, so it cannot block a load. It states the range the author supports and has tested — it is not a runtime version check. ## What is deliberately not implemented - **Non-Git workspaces.** Legacy 2.0.13 version currently supports Git workspaces only. A directory that is not inside a repository produces no summary, and the plugin never scans the tree to hash files. - **Diff review.** 0.1.5-rc.2 has no `changes-review` surface, so clicking the bar only expands and collapses a compact file list. File names are plain text, not links. - **Persistence.** Summaries live in the host process. Restarting the host drops them, and the bar honestly hides until the next turn completes. ## Known boundaries - A repository is summarized as a whole: when a Session's working directory is a subdirectory of one, the paths reported are relative to the repository root. - Two Sessions working in the same repository at the same time each measure their own turn boundaries, but they share one work tree; concurrent edits can appear in both Sessions' summaries. - Line counts follow the repository's own git configuration, including `.gitattributes` filters and `core.autocrlf`. - The file list is capped at 200 entries; the count stays complete and the list says how many were omitted. - The scratch directory is removed when a Session is disposed and when the plugin is disposed — but *not* when the host process is killed outright, where no disposer runs. A `SIGKILL`ed host therefore leaves one small directory per Session it was tracking under the system temporary root, named `dsh-chat-diff-legacy-*`. They hold only that repository's snapshot objects and a private index, and removing them is always safe. ## Install Two shapes, both standard DSH profile bundles (a node half in `exports["."]`, a browser half in `exports["./client"]` discovered through the `dsh.client` declaration, and a `cordis.patch.yml` that inserts this plugin's row and disables nothing). **Live link** — the profile follows this working tree, so an edit plus `npm run build` is enough: ```bash dsh plugin --profile add link:/path/to/dsh-chat-diff-summary-legacy ``` **Packed artifact** — a self-contained snapshot; redeploy after every change: ```bash npm run verify # typecheck, build, tests npm pack --pack-destination release # → release/-.tgz dsh plugin --profile add "file:$PWD/release/-.tgz" ``` Either way the plugin only takes effect once the **host process** has loaded it, so restart DSH Desktop afterwards. The browser half alone can be picked up by a page reload; the node half cannot. ## Deployed on this machine Installed into the live `~/.dsh` profile on 2026-09-24 — first as a packed artifact, then switched to a **live link** — and verified against it: - `profiles/desktop/package.json` lists `"dsh-chat-diff-summary-legacy": "link:/Volumes/Parallels_SSD/ai/ui-legacy"`, and `profiles/desktop/node_modules/dsh-chat-diff-summary-legacy` is a symlink resolving back to this directory. - Switching shapes changed exactly one dependency spec: 11 dependencies before and after, none added or removed, every other spec identical, the `dsh.profile.bundles` list unchanged at 13 entries, and the lockfile's key set unchanged at 382 entries. All ten third-party plugins still resolve at their declared versions. - The packaged `--dump-config` composes the real profile with `- id: chat-diff-summary-legacy` present at the same position, and no row of this plugin is disabled; the four profile files are byte-identical before and after that check. - The node half is loaded into the host process, so a **restart** is what makes a change live. The browser half alone comes back on a page reload. ## Development ```bash npm install npm run typecheck npm run build # lib/index.js (host) and lib/client.js (browser) npm run test npm run verify # all three, in order ``` The browser half is loaded through `window.__ModuleLoader__`, so `react` and `react/jsx-runtime` are declared external — both in `tsdown`'s `external` option and in `peerDependencies`, because `tsdown` 0.15 has no `deps.neverBundle` key and an unrecognized option is a silent no-op that would ship a second React (`tests/bundle.spec.ts` asserts the bundle's only two `require` calls). No `@deepseek-ai/*` package is imported at runtime by either half: every host service this plugin consumes is declared structurally in `src/host.ts` and `src/client/contract.ts`, with the shipped 0.1.5-rc.2 call site quoted beside each member.