- banner/intro/footer three-part closure-factory wrap (verbatim from
packages/client/tsdown.client.ts), intro now owns var module/exports
- deps.neverBundle/alwaysBundle guards: host keeps production deps
external, client keeps only module-table specifiers external
- bundle purity gate: any non-requested @deepseek-ai/* value import is
a build error (INLINE_SAFE / vendored-library / generated-remote
allowances mirrored)
- NODE_ENV + import.meta.env defines for inlined node-idiom deps
- client sourcemap on, matching the official artifact contract
- client smoke test now runs the bundle verbatim through the
__ModuleLoader__ handoff instead of slicing the factory body by
exact output strings (decoupled from bundler layout)
Adapt to DeepSeek Harness 0.1.1's multimodal pipeline (and the vision
capability of DeepSeek-V4-Flash-Vision-Exp): a user message carrying
durable image refs expands into a card layout — prose in the text card,
image attachments as thumbnail cards (name, normalized + original
dimensions, stored size), and unrecognized blocks as raw JSON. Image
blocks in assistant messages and tool results (e.g. read_image output)
now render too, instead of being dropped or dumped as JSON.
Thumbnails load through the harness conversation service's resolveImage
(the same session-authorized loader the chat history uses); when the
service is absent the cards degrade to metadata only. The text card
uses an un-padded RichCard variant so all cards align at one indent.
Sanitize the contextTimeline projection into a render-safe shape at the
services boundary (collections -> arrays, non-object entries dropped,
current -> numeric breakdown, wrong-typed scalars dropped) so a corrupt
or failed host payload degrades to empty sections instead of throwing
during render, keeping every usable piece of data visible.
Wrap the Context tab and /context dialog bodies in a React error
boundary (styled error card + Retry) as the backstop for any residual
render error; harden fmtTime against invalid timestamps, headersOf
against malformed epochs, and the cache-hit/cost math against NaN.
Add client regression tests: corrupt payloads render with usable data,
the modal degrades, and a throwing projection reader lands in the error
card.
dsh 0.1.1-rc.1 changed the session-projection unit contract: registration
now reads stateSchema (persisted fold state) + wire (client view), and a
unit without a wire block is treated as host-only — its value is never
delivered to the browser. The units still emitted only the pre-0.1.1
fields (schema + top-level view), so on 0.1.1-rc.1 the Context tab stayed
on its loading screen forever: contextTimeline/contextHeaders never
arrived through useProjection.
Emit BOTH contracts on each unit: schema/view for dsh <= 0.1.0-rc.8, and
stateSchema/wire for dsh >= 0.1.1-rc.1 (each registry reads its own
fields off the same definition and ignores the extras). Added a strict
timelineStateSchema for the persisted state (the cold-read rung also
parses checkpoint rows with it) and mirror checks in host.test.mjs.
Verified against the real registries of both versions (snapshot
delivery, change feed, checkpoint, viewCheckpoint, restore) and the real
plugin-install flow on the 0.1.0-rc.7 and 0.1.1-rc.1 CLIs.
Stats board gains an 预估费用/Est. Cost cell pricing the complete
session's billed tokens at hardcoded DeepSeek V4 list prices: the host
folds per-request usage totals (peak/off-peak split, cache hit/miss
input, output incl. reasoning) from the session log, flash and pro on
any provider, displayed in the locale's currency. The cell carries a
"?" marker whose hover bubble explains the whole-session estimate and
lists the per-1M-token price table straight from the pricing table, so
the printed rates can never drift from the math.
The contextTimeline fold persisted `undefined`-valued properties (model/provider/lastModel/contextWindow at init, pendingShadowedSeqs set to undefined on consume, and turn/step written as undefined when absent from assistant/message data). The harness projection cache snapshots the whole session checkpoint through the lossless-JSON snapshotter, which rejects any `undefined` property — so every projection-cache write failed and the `title` projection (session list) degraded after restart. stateVersion 3→4 discards stale rows; a regression test now drives the real snapshotJsonValue.
Fixes#5, #7.
Co-authored-by: kiligzzz <kiligzzz@users.noreply.github.com>
Co-authored-by: Bowen Liang <liang.bowen.123@qq.com>
The context browser's detail cards (system prompt, tool description,
user/assistant message and injection bodies) gain a segmented Raw |
Markdown switch at the card's top-right corner — the same pill chrome
as the trend chart's Step/Turn control. Markdown is the default view,
rendered through the harness's shared MarkdownText (GFM, sanitized,
resolved from the platform module table — zero plugin-side markdown
dependency); Raw restores the exact source <pre>. Each card owns its
mode state via the new shared richText kit (explicit render props, no
React context), so two open details switch independently. Reasoning
blocks, tool-call args and tool results stay raw.
Markdown chrome is scoped to the detail cards: base size 13px (a
proportional face matching the raw view's 12px monospace in visual
weight), headings collapse to a weight-only bump, inline code keeps
full size and is pinned under the paragraph line so wrapped chips no
longer stick across lines.
The /context dialog drops the last-10-turn history chart (TrendChart +
RequestDetail + markers) and hosts the shared ContextBrowser card
instead, so step picking, accordion, reconstruction and paging ride the
same component as the tab. The modal now reads the contextHeaders key for
the browser's system/tools sections, its command description is updated,
the dead lc-modal-trend rule is removed, and the modal render test is
rewritten to assert the embedded browser.
The current-composition bar gains a striped band over the rightmost
(1-ratio) of the window (AUTO_COMPACT_RATIO = 0.8, mirroring DSH's
dsh-compaction-basic default thresholdRatio, which DSH does not publish
to plugins): hovering it explains that this area is auto-compaction
headroom, normally not actually occupied, and clears the segment hover.
Rendered whenever the window is known, on both the overview card and the
/context modal bar, laid out in window units so it stays correct once use
passes the threshold. Mirrored/detail bars stay clean.
Each category row gains two direction-colored pills — an element-count Δ
after the item count and a token Δ hugging the left of the token figure —
each hidden while its figure held. Both baseline against the PREVIOUS
turn's last request regardless of step/turn granularity (live = last
request), with a short caption in the card header stating that baseline.
The count pill's redundant tooltip is dropped now that the token swing is
a visible pill.
The Current Composition legend stops being wrapped inline chips and
becomes an auto-flowing grid: each category is a label-left /
numbers-right cell (as many columns as the card fits, fewer as it
narrows) with the name bold at rest and a pointer cursor on hover.
The chip hover-link assertion now compares the label text via textOf,
since the label is its own span.
The context stats board gains a right-most 缓存命中 cell that reuses the
token-meter tokenUsage projection read through the standard useProjection
seat — the exact same data and buckets as the chat stats line below the
input box. The share is truncated (not rounded) to two decimal places,
with a float-noise guard; absent the projection it shows a dash. Adds the
bilingual stats.cacheHit label and test coverage (sixth cell, 80.00%
case, and a truncation proof).
Each tool row in the Context browser now reads as three stacked titled
sections:
* Description - the prose, in its own card
* Parameters - one row per JSON-Schema property (name, type, required
chip, description); type labels cover the common
vocabulary (array<inner>, object{N}, enum, anyOf/oneOf
unions)
* Raw JSON - collapsed behind a slim toggle; the <pre> only mounts
when opened, so the default body stays compact and the
JSON.stringify pass runs on demand instead of every
render
The two top sections share one card chrome (bordered block with a small
title), so a reader can scan them as siblings rather than mixed prose and
UI.
List the HeaderTools largest-first instead of the producer's header
order, mirroring the overview's top-tools chips. Tests: extended the
header fixture with a deliberately out-of-order pair and assert the
rows come back ranked.
Make the overview '工具定义 Top' label, its '等 N 个' overflow link and
each tool chip clickable (hover underline) and bridge them into the
Context browser: the label/overflow link open the tools category, a chip
also expands that specific tool's row. One-shot focus is applied by the
browser and cleared back so the same chip can be clicked again.
Clicking a bar in the History chart locks its step; the browser's step
picker now follows that pin, and unpinning returns the browser to the
live surface (with the same accordion reset as a manual pick). Hovering
another bar still previews transiently over the pin.
Row hovers and the shared composition-bar hover (segment brighten, dim,
free-track outline, occupied frame, tooltip, legend chip) now glide
instead of snapping. The tooltip and occupied frame stay mounted with an
opacity toggle so they fade out on leave too, not just fade in.
While the browser shows the live step, hovering its category rows or its
composition bar lights the matching segment + legend chip on the Current
Composition card, and hovering the overview lights the browser's category
row and bar segment back. The link is gated on the live step: a pinned or
previewed step has a different composition, so its hover never leaks into
the overview (and vice versa). The mirrored browser bar renders no tooltip
of its own, so a cross-card hover floats only one tooltip.
Hovering a history bar now transiently previews that step in the Context
browser — picker, meta line, composition bar and category lists all
follow the pointer, with a 'preview' marker in the meta row. Leaving
the chart returns the browser to its own picker selection; a seq trimmed
out of retention is ignored. Accordion state survives the hover, so one
open category can be scrubbed across steps for comparison.
Injections land on the surface first, so in a long session the plain
maxNodes tail window dropped their identity while their tokens kept
counting (sums cover the full surface) — the browser's inject section
showed a gray '0 items' row with a real token sum and no way to see
what was inside.
The served slice is now the newest maxNodes tail PLUS every live inject
node older than the tail (they are few; the concatenation stays
seq-ordered). droppedNodes and surfaceFloor count only the unserved
non-inject overflow, so the 'N earlier messages' note no longer
double-counts pinned injects, and per-step reconstruction sees them too.
Expanding a Context browser element whose seq is outside the loaded
conversation window now pages older history in automatically (one page
in flight, up to 20 pages) until the join hits, instead of leaving the
reader with the 80-char preview plus a static note.
The pagination verb reaches the card through the harness's
sessions.provide channel: the plugin contributes a loadOlderHistory
standard prop resolved to session.loadOlder(), so every session-scope
slot component receives it. Hosts without the channel (or a duplicate
refusal) fail soft to the previous preview-plus-hint degradation.
The events card header now carries four pill buttons (注入/压缩/剪枝/切换)
instead of a select: all picked by default, clicking an unpicked kind adds
it (A -> A+B -> ...), clicking a picked one removes it, and removing the
last one resets to all. Each kind gets a distinct hue shared between the
pills and the row chips (绿/红/紫/蓝); the hint subtitle and the filter
dropdown are gone.
Each event row now carries a kind chip (注入/压缩/剪枝/切换) colored by
impact direction (+ adds, - frees, ⇄ neutral), the events card header
gains a one-line hint about the block's meaning, and the native title
tooltip attaches only while the label actually truncates (measured after
every render and on resize) instead of always.
Side-by-side head row (3:2 flex, wraps when tight): plugin name, version,
and GitHub repo as whole-cell links (name/version -> GitHub/npm), with the
version/repo baked in from package.json via esbuild define. A once-per-page
npm registry check adds an 'Update!' badge + update-command tooltip on the
version cell whenever the registry release differs; failures stay silent.
Bilingual strings, cell-hover underline + brand border affordance.
Drop recycled/switches/est/actual-prompt/output cells and split the
single recycled counter into two — compactions and prunes — so the
counts are distinguishable in the board. Bold the cell labels and
center the values for a cleaner scan.
token-meter is a core service that owns estimation and replay (its docs:
'the singleton ctx.tokenMeter directly owns estimation and replay'), and the
published package registers three projections on the shared
SessionProjectionMap — tokenUsage / contextPressure / contextBreakdown.
The host was duplicating the contextPressure fold (pressureTokens +
sampledSurfaceTokens + occupancyWindow, sampleUsage on assistant/message and
usage chunks) and the client was rendering from that mirror.
Host: the occupancy mirror leaves the fold entirely (assistant/chunk is now
structurally ignored — same reference, no change feed). stateVersion 1 -> 2
so cached rows from the old fold are discarded. The strict wire schema no
longer accepts occupancy; the shared type keeps it optional as legacy for
downstream consumers.
Client: the headline reads the official token-meter contextPressure
projection via the useProjection seat (the same key the chat's context ring
reads); absent key/value degrades to the existing derived fallback (last
request prompt + surface movement), so the UI renders identically in every
deployment. UI functionality is unchanged — same headline, same percentages,
same composition anchoring.
diff-fold.mjs is repurposed: the plugin no longer has an occupancy fold to
compare, but its heuristic composition (current.system/tools + surface total)
must stay equal to the official contextBreakdown projection — verified
MATCH on three real session logs.
Tests: host asserts occupancy is absent + chunk reference stability; client
feeds the official projection through the stub and asserts the same UI text.
Verified against the harness source before locking in:
- agent-loop appends request/header (reason 'change' when the envelope
differs) BEFORE request/context per request, so a model switch is always a
header change — firing the model event only there equals the durable
record, and route/capacity metadata can never double-fire it.
- dsh-llm TokenUsage buckets are disjoint: billed prompt-side =
inputTokens + cacheReadTokens + cacheWriteTokens, outputTokens already
contains reasoningTokens — the fold's prompt/output mapping was already
correct; comments now cite the official semantics.
No behavior change; new assertions cover the switch event (exactly one, from
model-a to model-b), provider-only changes (route update, not a switch), and
the usage mapping (1000 = 800+150+50; output 30).
The docs' plugin contract expects an exported Config (Standard Schema) so
invalid entry config fails the load loudly, and the harness config principle
is 'anything two deployments may want to set differently is a configuration
field'. The fold previously hardcoded MAX_REQUEST_STEPS / MAX_KEPT_TURNS /
MAX_EVENTS / MAX_NODES in fold.ts.
- config.ts: exported Config (zod, implements Standard Schema v1 — the same
validator cordis uses via Config['~standard'].validate) with strict,
defaulted fields; resolveBounds() turns entry config into FoldBounds.
- fold.ts/timeline.ts: bounds thread through applyTimeline/trimState/
buildTimelineView; the projection unit is now created per instance by
createContextTimelineDefinition(config), so cordis config edits hot-reload
(HMR) like any other entry config.
- index.ts: apply(ctx, config) registers the config-bound unit; Config is
re-exported for patchers and the config catalog.
- Defaults equal the previous constants, so behavior is unchanged; persisted
state shape is untouched (no stateVersion bump — bounds are retention only).
- tests: host test drives a bounded instance (maxNodes/maxKeptTurns).
Typing /context in the composer opens a centered dialog with the current
composition (provider-anchored headline, stacked bar, legend) and the
last-10-turn trend chart — the same pushed contextTimeline projection the
tab renders, no RPC, no polling.
The command is the plugin's own '/' trigger source, fully client-side:
no host descriptor, no session log records, nothing model-visible. The
composer token is consumed only when the modal closes (deferred
slash/input-consume-token with span / bare-token guards, fail-soft if the
draft changed meanwhile). Esc, backdrop click, and the close button all
close it and restore composer focus.
Also extracts the provider-anchored headline derivation shared by the tab
and the modal (headline.ts).
The composition bar lays segments out against the FULL context window (width
= x / window) while legend/tooltip percentages are shares of the OCCUPIED
part (x / used) — so a slice that is half the used context reads ~20% wide
when the window still has headroom, which looks contradictory.
On hover (segment, legend chip, or the free track) a dashed frame now
outlines the occupied region (left edge to the used/window boundary), making
the 'share of used' reference visible; the frame renders only when a free
track exists (otherwise width already equals the percentage) and is
pointer-events:none. Segment tooltips now append 'of used context'
(zh: 占已用上下文) and legend chips carry the same hint as a title.
Adds the .lc-occupied-box style (shared --dsw-alias tokens), one i18n key per
locale, and render-test assertions that the frame appears on hover and
clears when hover leaves.
All view components are now tsx JSX function components instead of
hand-rolled h() factories; the bundle externalizes the framework seed words
(react, @deepseek-ai/dsh-client-ui-primitives) so the loader module table
resolves the single shared framework instance at runtime.
Reuses shared ui-primitives where a clean fit exists (injection and
model-switch event glyphs -> IconPlusOutline16/IconBranchOutline16); the
bespoke data-viz chrome (charts, shared-hover tooltip) intentionally stays
custom but continues to draw the shared --dsw-alias-* tokens.
esbuild uses the classic JSX transform, so the emitted createElement trees
are isomorphic to the previous h() calls and the element-parsing tests, the
chart-render assertions, and the real-React repro all stay green unchanged.
Adds @deepseek-ai/dsh-client-ui-primitives as a type devDep and documents the
framework-reuse findings in docs/redesign.md §8.
Replace the custom /dsh-context RPC channel + 2s client polling with a native
contextTimeline session-projection unit: the host registers one pure
init/apply/view unit on ctx.sessionProjections (framework-driven, persisted by
the projection cache, streamed via session/projection push frames), and the
client reads the finished value through the framework standard
useProjection seat. Deletes the client stale-while-revalidate cache and the
local service type stubs; grows the modern shelf registration (locale) and
adds the published @deepseek-ai/* types as devDeps plus zod as the single
runtime dependency (wire schema, kept external in the host bundle).
Fixes tool-result node attribution to read the durable source.callId /
content toolCallId instead of the never-present block.callId (tool labels had
silently never rendered).
Retention is tightened to trim by whole turn-runs deterministically (bounded
state, no 1200<->1500 oscillation under incremental folding).
Tests now drive the projection unit directly (host) and feed the view through
a useProjection stub (client); all chart/render assertions preserved. Adds
docs/redesign.md (review + target architecture, phases 0-3 landed) and
docs/CLIENT_SURFACE.md (client-side audit).
The context tab always showed less than the official chat context
indicator because the two sides used different rulers: the plugin
displayed its fixed-density heuristic total (4 chars/token, which
systematically undercounts CJK text and JSON schemas by ~10-15%),
while the official ring shows provider-reported billed tokens. The
running host also predated the occupancy feature entirely, so the
client fell back to the raw heuristic headline.
Core change: totals are anchored to the provider sample, heuristics
only supply the composition ratios (the official ContextMeter's own
trick):
- host: fold a contextPressure-equivalent occupancy projection
(pressureTokens / sampledSurfaceTokens / occupancyWindow) and serve
it on the snapshot; usage is sampled BEFORE the message joins the
surface, and streamed assistant/chunk usage samples count too, so
steps that fail mid-stream no longer fall back to pure heuristics.
- client: headline = occupancy.projectedTokens, with a last-request
fallback deriving the same figure against older hosts (a page
refresh fixes the live view before any server restart).
- composition bar, legend and trend bars are re-proportioned to the
anchored total (ratios stay heuristic, values marked ≈); the
conflicting 'estimate ≈ n (~p%)' second percentage is gone.
Verified by tests/diff-fold.mjs (kept as a differential regression
tool): the plugin's occupancy equals the official
contextPressure.projectedTokens to the token on all 33 real session
logs, incl. a 17k-event log with 9 compactions and a chunk-only-usage
session.
The overview's headline is now the provider-reported CURRENT occupancy
(contextPressure formula: newest usage sample + heuristic surface movement)
— the same figure the official chat ring shows — with the heuristic estimate
as the secondary label. On the live session: 475.1k (48%) provider vs 409.0k
(41%) heuristic; the gap is the provider tokenizer vs the 4-chars/token
heuristic (CJK-heavy content undercounts ~15%), not missing data — our
heuristic fold matches dsh's official fold to the token.
The producer's compaction shadow price covers the shadowedSeqs — which can
include prune replacement nodes whose own seqs lie beyond the declared range
end. Removing by the range left those nodes in the surface and overcounted
messages by 8 x the replacement price (10,416 tokens on the live session).
Removal now follows the armed shadowedSeqs (per-category bookkeeping kept),
making our heuristic totals exactly match dsh's official contextBreakdown
fold. Also: provider-based actual occupancy in the overview now uses the
official contextPressure formula (sample + surface movement), and the
missing 'overview.actual' dict key is added.
- replace the delayed native title with an instant custom .lc-chart-tip glued
to the hovered bar's column (single line: turn/step, time, total, actual)
- turn-aware dimming: while a turn is focused (bar or strip hover), bars and
strip blocks outside the active turn fade to 35%; the whole current turn
and its strip block stay fully opaque
- shared/types.ts: single wire contract (type-only) used by both halves
- host/: services (contracts), pricing (token-meter), fold (incremental fold
+ retention), snapshot (build + event turn/step attribution), index (RPC)
- client/: i18n, styles, format, categories, viewkit (shared deps),
cache (per-session stale-while-revalidate), components/* (one module per
component), view (composition root), index (plugin entry)
- first-screen: initial render seeds from the session cache, polling pauses
while the tab is hidden (resume + immediate fetch on visible), and a
transient fetch error never blanks already-visible data
- build.mjs: bundle:true for both halves (react external in the client
bundle); the client half is now covered by tsc --noEmit for the first time