ewoowe/dsh-session-messages-plugin

session-messages-plugin

Searchable message overlay for DSH sessions: jump to any loaded message with the keyboard.

待分类

dsh-session-messages

English | 中文

An in-session message viewer with two surfaces:

  1. Message list (Ctrl+S by default): opens a list of the messages loaded in the current session; pick with the arrow keys and Enter (or a click) jumps the transcript to that message. The list can be searched — see Search.
  2. Viewport strip (optional, off by default): a block centred on the session header showing the message you are currently reading, with its clock, usage and duration. See Viewport strip.

This is a standalone plugin installed into a profile. It does not modify any source of deepseek-harness (packages/ is untouched).

The message list: a searchable overlay over the transcript, with the loaded-message count and the session's token totals

Languages

The plugin's own copy ships in seven languages, all in src/client/locales.ts:

LocaleRegistered asNotes
enbuilt-in, together with zhThe source of truth; every other dictionary mirrors it key for key
zhbuilt-in, together with en
ja, ko, es, fr, delanguage-pack localesOnly the dictionary is this plugin's; the definition that makes them selectable belongs to a language pack (dsh-catppuccin in this profile)

Two things about that split are deliberate:

  • This plugin never calls addLanguage. The language pack owns the definition, and calling it here would throw against the pack's existing one. Declaring a language on the strength of a single namespace would also put a mostly-English entry in the picker.
  • Every dictionary is typed Record<MessagesKey, string>. Adding a key to MessagesKey fails to compile until all seven translations exist, so no locale can silently fall back to English for a key that was merely forgotten. That guarantee matters more than the en fallback chain does.

Every user-facing string is read through the locale seat, including the entry's name in the slot ledger — a thunk, so it follows a locale switch without re-registering. The clock stays the host's: it is a bare instant with no label.

If the profile carries no language pack, the five pack locales are simply not selectable and their dictionaries sit unused. Nothing else changes.

A host pill's label cannot be trusted. The host's own pills ship in zh and en alone, so under any pack locale they fall back to English and Usage 1.06k tok lands in a Japanese UI. The two numbers on each row are therefore read in halves: the value still comes from the host's pill (per-turn tokens and wall time are not derivable from where this plugin sits), while the label is this plugin's own turnUsage / turnDuration.

Keeping one layout across languages

A longer label must not rearrange a surface. Three mechanisms, one per way it could:

  • The dialog's rows are language-proof by construction. The preview is flex: 1 with min-width: 0, so it absorbs every width difference, while the stats column and the clock are flex: none and stay pinned to the right edge — the clock on the preview's first line, in every language. The dialog title and the header's session totals ellipsize instead of pushing the close button and the loaded count out of the row.
  • The strip's two capsules cannot reflow — they are Tags, which are nowrap, so their length directly decides how much room the message gets. The seven languages therefore give the preview different widths, and that is accepted: shrinking a label costs accuracy. An earlier version cut the cache-hit label down to a bare Caché / Cache — the object with no metric — and each language now uses its own standard short term instead (Cache hit / 缓存命中 / キャッシュ率 / 캐시 적중률 / Aciertos de caché / Taux de cache / Cache-Treffer): terminology outranks a few pixels.
  • The strip's session fields are read reactively, through the seat's standard useProjection rather than through an injected face. A polled read could come back empty before the session binding existed, which dropped the model and the cache share for a tick — one language looking different from another for a reason that had nothing to do with language.

Layout

dsh-session-messages-plugin/
  package.json        dsh.bundle + dsh.client declarations, exports map
  cordis.patch.yml    the layer patch (registers itself as a Loader entry, and its config)
  build.mjs           build script (tsdown's programmatic API)
  tsconfig.json       IDE type resolution only; points at checkout sources (read-only)
  src/
    index.ts                  Node half: Config Schema + publishes config into the page
    shared.ts                 the config shape and resolution, shared by both halves
    client/
      index.ts                Browser half: registers the two slots and the configuration form
      transcript.ts           the transcript DOM contract (collectors both consumers share)
      search.ts               search: folded matching, hit ranges, excerpting (pure)
      model-names.ts          model display names: host catalog → id lookup (external store)
      turn-facts.ts           per-turn facts: folds the event window to turn → model + cache share (usage via the host's deriveTurnTokenUsage)
      overlay.tsx             the list overlay: collection and jumping
      hud.tsx                 the viewport strip (header action seat)
      use-messages-config.ts  resolve the live config (config form → page global)
      session-totals.ts       session-wide facts: projection reads + compact formatting
      settings-card.tsx       the bundle's configuration form on the Plugins page
      settings-form-holder.ts   the card's bound config form → overlay, one-way bridge
      locales.ts              the seven dictionaries (en, zh + five pack locales)
  lib/                build output (index.js / client.js)
  docs/               the screenshots the two READMEs embed (en / zh pairs)

Install

From npm (after a publish — installs the prebuilt artifact, no build authorization needed):

dsh plugin --profile web add dsh-session-messages

From a tarball (also needs no authorization, and is the way to exercise the real artifact before publishing):

npm pack                                   # writes dsh-session-messages-0.1.0.tgz
dsh plugin --profile web add ./dsh-session-messages-0.1.0.tgz

While working on the sources (this repository is the plugin's own, so it can be linked):

npm run build                              # re-run after any src change
dsh plugin --profile web add .

⚠️ Remove before switching install modes: an existing install — a link especially — shadows the new package.

dsh plugin --profile web remove dsh-session-messages

Then restart dsh web: a new bundle layer, and any change to an existing one, both need a restart.

Development

npm ci                                     # install dependencies
npm run build                              # writes lib/index.js + lib/client.js
npm run typecheck                          # tsc --noEmit

Versions are pinned rather than left to latest: the @deepseek-ai/* family publishes a stale latest tag (0.0.1-rc.1) while the line this plugin matches is 0.1.7-rc.1 under next — a bare install resolves to the wrong one. Bump these pins when the host moves.

overrides pins one more version for the same reason, one level down: tsdown asks for rolldown ~1.2.0, but rolldown@1.2.10 never published its @rolldown/binding-linux-arm64-musl binary, so a fresh resolve produces a lock file npm ci refuses as out of sync. rolldown is therefore held at 1.2.9, the last complete release. The entry can go once upstream publishes a full platform set again.

Usage

InputEffect
Ctrl+SOpen / close the message list
↑ ↓Move the highlight
Alt+↑ ↓, PageUp PageDownPage: the list scrolls one screenful while the highlight holds its place (it meets the edge at either end); with a maxRows small enough to fit one screen, pages switch whole
EnterJump to the highlighted message
Mouse move / clickMove the highlight / jump
WheelMove the highlight, one row per notch; a trackpad accumulates its much smaller travel into whole rows. Direction follows wheelInverted
EscClose

The paging Alt is ⌥ Option on macOS — the same physical key — and the hint bar prints whichever name the platform uses. On a MacBook with no dedicated paging keys, Fn+↑ ↓ works: macOS translates it to PageUp / PageDown.

Configuration

Edit the config: block of cordis.patch.yml, or override it by id session-messages in the profile's own cordis.patch.yml. The shortcut is not hardcoded in the source:

FieldDefaultMeaning
keysThe lowercase form of KeyboardEvent.key
ctrltrueWhether Ctrl is required
altfalseWhether Alt is required
shiftfalseWhether Shift is required
metafalseWhether Meta is required (Cmd on macOS, Win)
wheelInvertedfalseWheel direction. Off: scrolling up selects the previous row; on: scrolling up selects the next
maxRows50Most rows the list renders at once; the window follows the highlight a page at a time, so this doubles as "rows per page"
showHudfalseWhether the viewport strip is shown in the session header (see below). Off by default

For Cmd+S on macOS: ctrl: false, meta: true.

Editing it on its Plugins page

The configuration form on this bundle's page: the chord key, its modifiers, the wheel direction, the row cap and the strip switch

The same fields can be edited in the UI, without touching cordis.patch.yml:

Plugins (the sidebar entry) → dsh-session-messages → the form above that bundle's row list. It is open by default.

The form lists the chord key, the four modifiers, the wheel direction, the row cap and the strip switch. Every field carries its own Reset, a field the user layer has overridden is marked, and the header collapses the form and shows an unsaved badge. Save applies the draft; discard drops it. No restart is needed.

The values live in the settings namespace session-messages — the profile entry id in cordis.patch.yml — which the Node half owns by marking every Config field .volatile(); that is what makes them live-editable, and the browser half reads the same document through ctx.configForms. The form itself is contributed through the Plugins page's plugins.bundle.config seat, keyed by the package name: that key is what pairs the form with this bundle's page, so the two have to be renamed together.

Viewport strip

The viewport strip: a block centred on the session header showing the message being read, with its clock, usage and duration

The switch is showHud (the "Show the viewport strip" toggle in the configuration form, or cordis.patch.yml). With it on, a block appears centred on the session header and updates as you scroll:

21:36 · deepseek-chat  Data source: read the projection, not the DOM — the point is
                       reading projections.faceOf, which the client exposes, rather
                       than scraping the rendered DOM.
                                        Usage 1.2k · Ran for 12.3s · Cache hit 99%

(The two ends are really two outlined capsules: "when, and with what" on the left, "what it cost" on the right. The preview occupies up to three lines beside them — plain text cannot do that, see Why the ends are Tag.)

It is centred on the header, not on the viewport: it registers into conversation.session.header.actions (the seat the host declares for title-adjacent session actions — the same one ui-agent-preset's preset label and ui-jobs' job list use), but it does not queue up as one more item there. It takes itself out of flow and centres on the header box.

How the centring works

Both axes aim at the header box itself: horizontally at its centre line, and vertically at its centre line too.

Horizontal — pure CSS. The containing block of position: absolute is ui-conversation's .root, which declares position: relative for exactly this purpose (positioning context for slot-owned absolute chrome). The header spans that whole column, so 50% of .root is the header's own centre line; it stays put when the sidebar collapses or expands, where 50% of the viewport would not.

Vertical — measured, by reading <header>'s own box and taking its centre. The strip is out of flow, so nothing in CSS knows where that box is; measuring is the only way.

The anchor is the header rather than the title row, because "centred on the overlay" means exactly this header box: besides the title row it also carries the view tabs, so it is much taller than its first line. Anchoring on the row left the strip visibly high — that was the second version.

Two attempts worth not repeating:

TriedResult
The static position of an absolutely-positioned flex child (top: auto)The spec says such a child is laid out as if it were the sole flex item, so it should honour the parent's align-items: center. In practice it landed at the containing block's top edge and the strip was clipped by the window.
Anchoring on the seat's own boxesWhich of them collapses depends on the composition — headerActions is zero-height when the strip is its only child, the corner is display: none when empty — so it needed a chain of fallbacks. The header is one element that is always there; the chain was wasted effort.

Taking it out of flow is also the precondition for centring at all: the seat's headerActions is flex: none, and the row's free space belongs to titleCluster (flex: 1), so an ordinary in-flow item in that seat can never reach the middle.

top is written for the element's top edge and the transform is horizontal-only, so if the measurement never runs the strip falls back to its static position and stays visible instead of being pulled off-screen — the first version used translate(-50%, -50%) and disappeared entirely under the same conditions.

top is written straight onto the node rather than through React state: it is a function of layout, and routing it through state would put a state update inside the very frame loop that measures it.

The look is copied, not designed

It is copied from the preset label in the same seat (AgentPresetLabel.module.css):

PropertyValueRelation to the preset label
border-radius6pxThe same rounded rectangle, not a pill
backgroundvar(--dsw-alias-fill-tsp-secondary)The same translucent fill, not bg-layer-*
colorvar(--dsw-alias-label-secondary)The same label tier
min-height22pxThe same floor; no longer a fixed height, see below

The first version used "999 radius + a hard border + bg-layer-3 + label-primary" — none of the header's own chrome is built that way, so it read as a card stuck on top. To blend into a piece of UI, copy the recipe it already uses; do not invent one.

The preview is set to be read, not to be scanned

One deliberate departure from the preset label: the message body uses 14px / 20px (body size, matching the transcript) and is allowed to wrap, up to three lines, instead of the chip's 12px single line. This is something to read, not a label to scan.

  • The container is therefore min-height rather than a fixed height, and grows with the line count.
  • The preview's character cap is MAX_HUD_CHARS = 500, with the visual cap enforced by a three-line -webkit-line-clamp. The character cap is generous because narrow scripts (Latin) fit more into the same three lines; slicing earlier would show less. overlay.tsx's row previews use the same -webkit-box triple.

Why the ends are Tag

The clock and the usage / duration pair are the host's Tag primitive (tone="outline"), not spans styled to look like one. Tag brings its own geometry (999 radius, 1px 8px, 11px/17px, nowrap) and palette (0.5px solid var(--dsw-alias-border-l4) + label-tertiary), so nothing here restates any of it.

Why it had to change: all three parts used to be plain text on one shared background, and a reader had no way to see at a glance where the message started. An outlined capsule is the visual language of meta information; body copy is not. One level of contrast separates them.

Three things that then need no attention:

  • Tag's box, including its hairline, is exactly 20px — the same height as the preview's 14px/20px first-line box. For a single-line message the two capsules sit level with the text with no adjustment at all.
  • When the message wraps, the container's alignItems: center keeps them centred: both capsules centre on the whole message block, not on its first line (which, beside a two-line preview, made them look like they had drifted upwards).
  • Using the Tag component rather than copying its CSS: it is required from the module table (external), so what arrives is the host's already-loaded instance — consistent by construction, and immune to the host changing its styles.

All three parts are centred, which takes two things at once:

  • The preview is flex: 0 1 auto — it may shrink, but it must not grow. Letting it grow (1 1 auto) made the strip always as wide as maxWidth, pushing the clock to the left edge and the numbers to the right edge: three parts pinned to the ends of a wide box read as anything but centred. Without the growth the strip hugs its content and the three sit together as one group.
  • The container sets textAlign: center, so wrapped preview lines centre instead of aligning left.

What it shows: five fields, two granularities

The capsules split by axis: "when, and with what" on the left, "what it cost" on the right.

FieldGranularitySource
Clockthat messagethe row's IconActions label
Message textthat messagethe row's text (trailing timestamp leaf stripped)
Usage / durationthat turnthe two capsules in that turn's tail
Modelthat turnthat turn's assistant/message event, its message.source, folded out of the binding's event window (turn-models.ts). The display name is then resolved through remote.session.modelCatalog(), so it reads exactly like the composer's picker; a turn with no readable model says Unknown model
Cache hitthat turnthat turn's usage, folded by the host's deriveTurnTokenUsage, as cacheRead / billed input at the host's turn-dialog precision (one decimal)

The per-turn model comes from the session's own event window (binding.eventSource): each turn's assistant/message event carries message.source.{provider,model} — the very thing the host folds per-turn routes from — so the strip names the model of whichever turn it lands on, and scrolling back to a message sent before a switch no longer shows the model selected now. A turn outside the loaded window says Unknown model instead — deliberately not the session-wide modelSelection, which is a fact about a different moment and would be stated with the same confidence as the truth.

The cache share is per-turn too. The usage is folded by the host's own deriveTurnTokenUsage (@deepseek-ai/dsh-token-meter/client — the function ui-chat builds a turn's tail with) and printed through the host's formatter at the host's turn-dialog precision (one decimal, extra decimals near a full hit). Its rules are deliberately not re-derived here: which events count, when a final message supersedes a streaming sample, and that an incomplete turn yields nothing are subtle enough that a second implementation would diverge invisibly until someone compared two surfaces. A turn that is still running has no figure yet — the host fails closed on it — so the clause is simply absent.

There is a second route to per-turn data — the conversation.chat.node seat injects a useTurnData hook, and its occupant can derive a turn's wall time from node.location.turn.start/end — but it answers for the ONE turn its node renders, while the strip needs whichever turn sits under the fold and the list needs every turn. Nor can that seat simply be taken: its keys are ui-chat's own ChatNodeKinds, and occupying one would REPLACE ui-chat's renderer. The event window is how this plugin gets there instead.

The figures match the host's own: the denominator is the sum of the three billed input buckets (verbatim the host's billedInputTokens in StatsPills), and the percentage is a port of the host's formatCacheHitPercent (token-format.ts) — one projection, one algorithm, so the strip, the dialog header and the composer's pills print the same characters. That rule is worth stating: a near-full hit is not clamped to 99.9, it earns extra decimals (99.6 / 99.95), which is both true and visibly short of a full hit.

The model reads as the display name the composer's picker shows (for example DeepSeek-V4.1-Flash), resolved through remote.session.modelCatalog(); a model absent from that catalog (a retired one) falls back to its raw id, and an unreadable turn says "Unknown model". It is deliberately not read from ctx.modelDirectories — a selection surface that lazily creates per-session state and throws for a session outside the active list, which a read-only label should not pull in.

The width cap is therefore min(760px, 58vw) rather than something narrower — the capsules cannot compress, and a narrower cap would squeeze out the preview first, which is the very thing the strip exists to show.

pointer-events: none because it can overlap a long title's tail and must not swallow clicks.

One thing that comes for free: the header hides itself with display: none for a blank session, and the strip disappears with it as a descendant — the plugin never has to inspect session state.

Which message counts as "the one you are reading"

It is the last row that has reached the viewport's top band, not "the first row still on screen".

The reason is empirical: a user message is one line tall while its answer can be screens. Judged by "still on screen", the user message would have scrolled away long before the answer ended and the strip would go blank for most of it — which reads as broken rather than as empty. Judged by "has reached the band", it behaves like a sticky section header: it keeps naming the message you are reading until the next one reaches the band. While every row is still below the band (the reader is above the first message), it falls back to that first message.

The band (FOLD_BAND_PX) is not zero, and that matters. It is viewport top + LAND_OFFSET_PX + 2px:

  • landOnRow places a jump's target LAND_OFFSET_PX (24px) below the viewport top — that breathing room is what the offset is for. Written as a strict "has crossed the top" (top < viewTop), the target row has not crossed yet, so the strip named the row above it: every jump lagged a message behind.
  • The +2px is sub-pixel slack: the landed position measures 24.0000…, and a bare < would drop it.
  • A wider band also makes the strip flip to the next message slightly earlier while scrolling, which is the direction it wants to err in anyway.

LAND_OFFSET_PX therefore moved from overlay.tsx into transcript.ts, the contract layer both consume: the jump's landing arithmetic and th