Install
Inside DeepSeek Harness, with dsh-market
dsh plugin --profile web add dshmarket
Or from the command line
dsh plugin --profile web add github:kahomesl/dsh-chat-diff-summary-legacy
Installing runs third-party code with your own permissions — it can read your files, use your credentials and reach the network. Review the source first, and pin a commit (github:owner/repo#sha) when you can.
Screenshots
README
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 || >=0.1.7-rc.2 <0.1.8 || >=0.2.0-rc.1 <0.3.0, verified on0.1.5-rc.2(the kernel DSH Desktop 2.0.13 ships), on0.1.7-rc.2, and on0.2.0-rc.2(the kernel the current desktop build ships). See Compatibility. - Changed-file count in ordinary text;
+addedin the success token and-deletedin 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.
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 atturn/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:
- At
turn/start, the complete work tree is written as a tree object. - At
turn/end, the same again. - 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_DIRECTORYpoints at the scratch store, so new blobs and trees land there; the repository's own store is attached read-only throughGIT_ALTERNATE_OBJECT_DIRECTORIES.- Each snapshot writes through its own
GIT_INDEX_FILEinside 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 addagainst 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 |
| Verified | 0.1.5-rc.2 (the kernel DSH Desktop 2.0.13 ships), 0.1.7-rc.2, and 0.2.0-rc.2 (the kernel the current desktop build ships, macOS) |
| Untested | 0.1.5-rc.1, the 0.1.6 line, and other patches on any line |
| Out of scope | 0.3.0 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.2.x. This plugin has
been exercised against three pairings: DSH Desktop 2.0.13 + kernel 0.1.5-rc.2,
the previous desktop build + kernel 0.1.7-rc.2, and **the current desktop build
- kernel 0.2.0-rc.2**.
To read the kernel version you actually have:
# @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.2.0-rc.2
Why the upper bound is 0.3.0. Every host surface this plugin touches exists
in the 0.1.5 line: the conversation.input.dock slot, route registration through
ctx.connection.fetch, the session/event, session/disposed and
tools/pre-execute events, and the turn/start / turn/end / tool/result
Session events. Every one of them was checked in 0.1.7-rc.2 and checked again in
0.2.0-rc.2 (ctx.sessions.binding(id) still answers
{ sessionId, session, eventSource, ctx }), so the ceiling was raised to cover the
whole 0.2.x line; the 0.1.6 line is untested and 0.3.0 and later are out of
scope.
Why the range has three alternatives. semver admits a prerelease version only
when a comparator carries the same major.minor.patch with a prerelease of its
own. Written as a single alternative, >=0.1.5-rc.1 <0.3.0 therefore reads
0.1.7-rc.2 and 0.2.0-rc.2 as out of range under semver.satisfies() —
both of them kernels the table above calls verified. Each alternative names one
line that was exercised, so the three verified kernels are in range under plain
semver and under includePrerelease alike, while the 0.1.6 line, 0.1.8, and
0.3.0 and later stay out. tests/bundle.spec.ts pins the property.
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
- Diff review. 0.1.5-rc.2 has no
changes-reviewsurface, 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
.gitattributesfilters andcore.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 the shared workspace it belongs to is
retired, and everything is removed when the plugin is disposed — but not when
the host process is killed outright, where no disposer runs. A
SIGKILLed host therefore leavesdsh-chat-diff-legacy-*directories under the system temporary root, and each one carries a.dsh-chat-diff-legacy-scratch.jsonmarker naming the plugin, the kind of workspace and the measured directory. The next plugin start sweeps marked directories that have not been written to for 24 hours; a directory without a marker this plugin can read is never touched.
Non-Git workspaces
When a Session's working directory is not inside any repository, the plugin mints
a private repository inside a scratch directory and measures the directory
with it: git init happens there alone, so the measured directory gains no
.git, no index and no object store (tests/git.spec.ts asserts it). The
measurement is still entirely git's — add --all, write-tree,
diff-tree --numstat; no file is walked or hashed by this plugin.
One workspace per directory, shared by every Session in it. The workspace is keyed by
realpath(cwd), case-folded on Windows, so a second Session in the same directory reuses the first one's repository and object store instead of paying another whole-directory pass. A Session ending does not delete a workspace another Session is still measuring through: the last consumer out starts a five-minute idle window, and plugin disposal retires everything at once.The first pass no longer costs a turn its numbers. Reading every accepted file once measured 36.7 s and about 810 MB of temporary object store on a 1.7 GB / 28k-file directory with the default excludes. That pass now runs on the Session's chain — the host's tool gate awaits it — so the turn's baseline is written before its first mutating tool and the turn is measured like any other. Accuracy is worth the wait. The wait is bounded (
WARMUP_GATE_BUDGET_MS, 120 s by default): a pass that outlives it keeps running in the background, and the turn it outlived says in the log that it has no baseline rather than going quietly empty. From then on a turn costs a stat walk: 0.13 s on the same directory.A failed first pass is retried, not remembered. The failure, the attempt count and git's own words stay on the shared workspace, and a later turn retries behind an exponential backoff (1 s, doubling to a 5-minute ceiling). Diagnostics name the step that failed —
realpath,rev-parse,git init,git config,add --all(first pass or incremental),write-tree,diff-tree, a timeout or a disposal abort — with the Session, the directory, the exit code, git's truncated stderr and the elapsed time.Default ignore rules live in the private repository's
info/exclude; a.gitignoreinside the directory still applies:.git/ .hg/ .svn/ node_modules/ dist/ build/ out/ target/ coverage/ __pycache__/ .venv/ venv/ *.apk *.zip *.7z *.rar *.exe *.dll *.so *.dylib *.iso *.dmg *.msiNo line-ending translation: the private repository pins
core.autocrlf=falseandcore.safecrlf=false, so counts describe the bytes on disk rather than one repository's checkout policy.This mode trusts git's stat cache. The repository path re-hashes every path on every turn for correctness (see
snapshotTree); the private-repository path re-reads only what changed, because reading everything again is what it exists to avoid. On a filesystem with coarse timestamps (FAT/exFAT, some network shares) an edit that keeps both a file's size and its recorded timestamps can be missed. A directory that needs the strict behaviour belongs in a repository, where the repository path runs.A repository embedded in the directory follows git's own embedded-repository (gitlink) semantics: nothing inside it is reported file by file, and when its own commit advances the bar lists that directory as one entry without line counts (
diffSyntheticTreesrewrites the+1 -1git reports for a gitlink, so no line count is invented). For per-file detail inside it, point the Session's working directory at that repository.A Windows directory junction is followed, so content reachable through one enters the snapshot. The default excludes cover dependency trees and build output, not arbitrary junctions.
A user-level
core.excludesFileapplies on POSIX only: on Windows the environment this plugin hands git carries no user profile path, so git cannot read~/.gitconfigand only the directory's own.gitignoreand the default rules above apply.
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).
The install path is only where the plugin's source lives. The plugin registers
on the whole DSH profile that installed it and applies to every Session in that
profile, whatever disk or directory the project sits on — a link: specifier
points at source, it does not narrow the plugin's workspace. Which working tree
gets measured is decided per Session, from that Session's own
session.header.cwd. There is no fixed path, allowlist or project-root filter
anywhere in this plugin; process.cwd() appears only as the working directory of
the git --version and xcode-select -p probes, never as a measurement scope.
Live link — the profile follows this working tree, so an edit plus
npm run build is enough:
dsh plugin --profile <profile> add link:/path/to/dsh-chat-diff-summary-legacy
Packed artifact — a self-contained snapshot; redeploy after every change:
npm run verify # typecheck, build, tests
npm pack --pack-destination release # → release/<name>-<version>.tgz
dsh plugin --profile <profile> add "file:$PWD/release/<name>-<version>.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.jsonlists"dsh-chat-diff-summary-legacy": "link:/Volumes/Parallels_SSD/ai/ui-legacy", andprofiles/desktop/node_modules/dsh-chat-diff-summary-legacyis 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.bundleslist 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-configcomposes the real profile with- id: chat-diff-summary-legacypresent 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
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.
Comments
Comments live in GitHub Discussions. Sign in with GitHub to post or react.