dsh-balance-widget 0.5.5 → 0.6.0
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.
- package/README.en.md +22 -6
- package/README.md +22 -6
- package/docs/screenshot-corner.png +0 -0
- package/docs/screenshot-popover.png +0 -0
- package/lib/client.js +94 -6
- package/lib/index.js +37 -30
- package/package.json +3 -6
package/README.en.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[]()
|
|
11
11
|
|
|
12
|
-
A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) Web GUI
|
|
12
|
+
A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) **Web GUI and desktop app**: a persistent sidebar footer card shows the account balance and today's cost; clicking opens a five-tier cost breakdown (balance / last prompt with its session name / today-this-session / today-this-workspace / today-all-workspaces).
|
|
13
13
|
|
|
14
14
|
## Preview
|
|
15
15
|
|
|
@@ -28,7 +28,7 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
|
|
|
28
28
|
| **Peak/off-peak pricing** | ✅ Built-in official 2026-09-10 rate table, re-synced from the official page at startup and every 12h | Partial support |
|
|
29
29
|
| **Security** | ✅ API key stays in the host process; loopback-only guard | Varies |
|
|
30
30
|
|
|
31
|
-
**In one line**: *The zero-dependency, Node 24-ready balance/cost widget that never breaks `dsh web` boot.*
|
|
31
|
+
**In one line**: *The zero-dependency, Node 24-ready balance/cost widget that never breaks `dsh web` or the desktop app on boot.*
|
|
32
32
|
|
|
33
33
|
## Features
|
|
34
34
|
|
|
@@ -37,7 +37,7 @@ A balance & cost widget for the [DeepSeek Harness](https://github.com/deepseek-a
|
|
|
37
37
|
- **Today · this session cost (estimate)** — The current session's usage today (calendar day) × DeepSeek's official peak/off-peak price table. Follows the configured model (default `deepseek-v4-flash`, switchable to `deepseek-v4-pro`) and the Beijing-time peak/off-peak windows automatically.
|
|
38
38
|
- **Today · this workspace cost (estimate)** — Sums today's token usage × price across every session in the current workspace (anchored by the current session).
|
|
39
39
|
- **Today · all workspaces cost (estimate)** — Walks every session under `~/.dsh/sessions/` and sums today's (calendar day) token usage × price.
|
|
40
|
-
- **Peak/off-peak status** —
|
|
40
|
+
- **Peak/off-peak status** — a status dot before the card's amount is tinted by the current window (amber at peak / green at off-peak), a "Peak/Off-peak" tag sits next to the popover title, and hovering it shows the current price tier (input/output per 1M tokens).
|
|
41
41
|
- **Token usage** — Also shows the session's input (incl. cache hits) / output tokens.
|
|
42
42
|
- **One-click top-up** — a "Top up" link in the popover footer jumps to the official DeepSeek top-up page (platform.deepseek.com/top_up) in a new tab.
|
|
43
43
|
- **Sidebar card** — a persistent card at the sidebar footer shows balance and today's cost; globally visible, auto-refreshes every 60s (balance via the official API, costs parsed locally), and opening the popover triggers an immediate refresh.
|
|
@@ -63,7 +63,7 @@ host half (lib/index.js)
|
|
|
63
63
|
|
|
64
64
|
client half (lib/client.js)
|
|
65
65
|
ctx.slots.inject("sidebar.footer.action")
|
|
66
|
-
→ persistent sidebar footer card (balance + today's cost, peak-
|
|
66
|
+
→ persistent sidebar footer card (balance + today's cost, peak/off-peak status dot; a 36×36 icon button while the sidebar is collapsed)
|
|
67
67
|
→ click opens five-tier cost popover + peak tag + ⓘ term explanations
|
|
68
68
|
```
|
|
69
69
|
|
|
@@ -85,6 +85,8 @@ dsh plugin --profile web add "link:$(pwd)"
|
|
|
85
85
|
|
|
86
86
|
Then restart `dsh web`.
|
|
87
87
|
|
|
88
|
+
**The desktop app (DeepSeek Harness.app) is supported too**, with `desktop` as the install target: install it from the desktop app's sidebar "Plugins" panel, or add the package to `~/.dsh/profiles/desktop/package.json` (dependency plus `dsh.profile.bundles`), then restart the desktop app. Because the desktop app is launched by the GUI, note that this plugin shells out to nothing — there is no PATH to configure.
|
|
89
|
+
|
|
88
90
|
## Configuration
|
|
89
91
|
|
|
90
92
|
### Where the config file lives
|
|
@@ -172,7 +174,7 @@ This section is for the DSH Store / plugin audit: dependencies, runtime permissi
|
|
|
172
174
|
**Runtime permissions**
|
|
173
175
|
- `files`: reads only `~/.dsh/sessions/` session JSONL (cost stats); never writes or mutates any session file
|
|
174
176
|
- `network`: only the DeepSeek official endpoints — `api.deepseek.com` (`GET /user/balance`) and `api-docs.deepseek.com` pricing page (fetched every 12h); no third-party proxy
|
|
175
|
-
- `commands`:
|
|
177
|
+
- `commands`: **none**. Session logs are decompressed with Node's built-in zstd (`node:zlib`, needs Node >=22.15), so no `zstd` binary and no `PATH` dependency
|
|
176
178
|
- `credentials`: reads `DEEPSEEK_API_KEY` (resolved via the host credentials service), used only in the host process behind a loopback-only route guard; the browser never sees the key
|
|
177
179
|
- All host routes are bound to the loopback address and unreachable externally
|
|
178
180
|
|
|
@@ -183,12 +185,26 @@ This section is for the DSH Store / plugin audit: dependencies, runtime permissi
|
|
|
183
185
|
**Failure bounds**
|
|
184
186
|
- Balance fetch failure: the panel shows the error and keeps the last successful snapshot (no interruption)
|
|
185
187
|
- Pricing fetch failure: falls back to the built-in 2026-09-10 rate table, `pricingSource` marked `default` (`synced` once parsing succeeds)
|
|
186
|
-
-
|
|
188
|
+
- Node without built-in zstd (<22.15), or a session file that yields no frame: returns an actionable error instead of failing silently
|
|
187
189
|
- Missing/corrupt session files: that session is skipped; other sessions are unaffected
|
|
188
190
|
- All costs are estimates; the provider's bill is authoritative
|
|
189
191
|
|
|
190
192
|
## Changelog
|
|
191
193
|
|
|
194
|
+
### v0.6.0 — desktop app support, and a UI that matches the desktop shell
|
|
195
|
+
- 🖥️ **Desktop app support (DSH 0.1.7 / DeepSeek Harness.app)**
|
|
196
|
+
- **Session logs are no longer decompressed by the `zstd` CLI** — Node's built-in zstd (`node:zlib`) does it instead. The desktop app is launched by the GUI, so its PATH is only `/usr/bin:/bin:/usr/sbin:/sbin` and the Homebrew `zstd` is invisible: every cost tier (today's spend, last prompt) failed outright there
|
|
197
|
+
- **v4 session logs are decoded frame by frame.** The log is append-only: each flush appends its own zstd frame (a measured 726 KB v4 log held 97 of them). Node's one-shot decompressor returns only the first frame (258 B) — and silently dropping 99% of the data is worse than failing. Frames are now located by their magic number; v0 / v3 / v4 logs decode byte-identically to `zstd -dc`
|
|
198
|
+
- **The refresh glyph is resolved per DSH version.** 0.1.7 renamed product icons to size-neutral weights (`IconRefreshOutlineRegular` plus a `size` prop) and dropped `IconRefreshOutline14`; rendering the missing name as a component throws React #130, and the sidebar slot answers a crashed entry by dropping the whole card — the "card flashes, then vanishes on click" report. The glyph is now resolved from whichever name exists, with a local SVG fallback
|
|
199
|
+
- **The client inject declaration is empty**: `@deepseek-ai/dsh-client-runtime` was never a real package (absent in both 0.1.5 and 0.1.7); the browser half needs only react and the shell's seed modules
|
|
200
|
+
- 🎨 **Visual alignment with the desktop shell**
|
|
201
|
+
- Dropped the card's state border (green off-peak / amber peak, which read as "selected") in favour of a 6px status dot before the amount; fill, 12px radius and 14px amount text now use the same design tokens as the desktop's own session rows
|
|
202
|
+
- The card fills the sidebar column — it used to be flex-shrunk to 131px and overflowed by 8px
|
|
203
|
+
- **New collapsed-rail state**: in the 56px icon rail the card becomes a 36×36 icon button (matching the rail's other buttons) with the status dot as a badge
|
|
204
|
+
- **The popover is portalled into `document.body`**: the sidebar column clips its children (`overflow:hidden`), so in the rail the panel was cut off at the sidebar edge. Its width now follows the card (measured at open time, 240px floor) and re-anchors through a `ResizeObserver` while the sidebar or the window is resized
|
|
205
|
+
- Shadow and focus ring now come from the shell's tokens (`--dsw-shadow-lv3`, `--dsw-focus-ring-*`)
|
|
206
|
+
- 📄 Docs: both README screenshots regenerated for the new look
|
|
207
|
+
|
|
192
208
|
### v0.5.5 — README assets now render on the npm package page
|
|
193
209
|
- 🐛 **Fixed**: the screenshots and the language switch used repository-relative paths (`docs/screenshot-corner.png`, `README.en.md`). The npm package page renders the README body only and does not resolve in-repo paths, so both screenshots and the language link were broken on npm. They are now absolute URLs: screenshots via `raw.githubusercontent.com`, the language switch via a GitHub blob link
|
|
194
210
|
- 📦 **Scope**: documentation only; no code change
|
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[]()
|
|
11
11
|
|
|
12
|
-
DeepSeek Harness (DSH) Web GUI
|
|
12
|
+
DeepSeek Harness (DSH) **Web GUI 与桌面版**通用的余额与成本小部件:侧边栏底部常驻卡片显示账户余额与今日花费,点击弹出五层级成本明细(余额 / 最近提问·标注会话名 / 今日·本会话 / 今日·本工作区 / 今日·所有工作区)。
|
|
13
13
|
|
|
14
14
|
## 效果预览
|
|
15
15
|
|
|
@@ -28,7 +28,7 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:侧边栏底部常
|
|
|
28
28
|
| **峰谷定价** | ✅ 内置官方 2026-09-10 峰谷价表,启动时与每 12h 自动从官方页刷新 | 部分支持 |
|
|
29
29
|
| **安全性** | ✅ API key 仅在宿主进程,loopback-only 守卫 | 参差不齐 |
|
|
30
30
|
|
|
31
|
-
**一句话**:*零依赖、Node 24
|
|
31
|
+
**一句话**:*零依赖、Node 24 就绪、在 `dsh web` 与桌面版都不拖垮启动的余额/成本小部件。*
|
|
32
32
|
|
|
33
33
|
## 功能
|
|
34
34
|
|
|
@@ -37,7 +37,7 @@ DeepSeek Harness (DSH) Web GUI 的余额与成本小部件:侧边栏底部常
|
|
|
37
37
|
- **今日·本会话成本(估算)** — 当前会话今天(自然日)产生的 token 用量 × DeepSeek 官方峰谷定价表计算,随当前会话模型(默认 `deepseek-v4-flash`,可在配置中改为 `deepseek-v4-pro`)与北京时间高峰/空闲时段自动切换。
|
|
38
38
|
- **今日·本工作区成本(估算)** — 遍历当前工作区(由当前会话锚定)下的所有会话,累加今天的 token 用量 × 单价。
|
|
39
39
|
- **今日·所有工作区成本(估算)** — 遍历 `~/.dsh/sessions/` 下所有工作区的所有会话,累加今天的 token 用量 × 单价。
|
|
40
|
-
- **峰谷状态标签** —
|
|
40
|
+
- **峰谷状态标签** — 卡片金额前的状态圆点按当前时段着色(峰时琥珀 / 谷时绿色),弹框标题旁显示「峰时/谷时」标签,悬停可查看当前价格档位(输入/输出单价)。
|
|
41
41
|
- **Token 用量** — 同时展示输入(含缓存命中)/ 输出 token 数。
|
|
42
42
|
- **一键充值** — 弹层底部「去充值」链接直达 DeepSeek 官方充值页(platform.deepseek.com/top_up),新窗口打开。
|
|
43
43
|
- **侧边栏常驻卡片** — 侧边栏底部(设置上方)显示余额 + 今日花费,全局可见;每 60 秒自动刷新(余额走官方接口、成本为本地会话解析),打开弹框时也会立即刷新一次,无需手动操作。
|
|
@@ -58,7 +58,7 @@ host 半区 (lib/index.js)
|
|
|
58
58
|
|
|
59
59
|
client 半区 (lib/client.js)
|
|
60
60
|
ctx.slots.inject("sidebar.footer.action")
|
|
61
|
-
→ 侧边栏底部常驻卡片(余额 +
|
|
61
|
+
→ 侧边栏底部常驻卡片(余额 + 今日花费,峰/谷时段状态圆点;侧边栏收起时变为 36×36 图标按钮)
|
|
62
62
|
→ 点击弹出五层级成本明细 + 峰谷标签 + ⓘ 名词解释
|
|
63
63
|
```
|
|
64
64
|
|
|
@@ -80,6 +80,8 @@ dsh plugin --profile web add "link:$(pwd)"
|
|
|
80
80
|
|
|
81
81
|
装完重启 `dsh web` 生效。
|
|
82
82
|
|
|
83
|
+
**桌面版(DeepSeek Harness.app)同样支持**,安装目标是 `desktop` profile:可在桌面版侧边栏的「插件」面板里安装,或把包加进 `~/.dsh/profiles/desktop/package.json` 的依赖与 `dsh.profile.bundles`;装完**重启桌面版**生效。桌面版由 GUI 启动,本插件不依赖外部命令行,无需额外配置 PATH。
|
|
84
|
+
|
|
83
85
|
## 配置
|
|
84
86
|
|
|
85
87
|
### 配置文件在哪
|
|
@@ -169,7 +171,7 @@ DSH 的插件配置统一放在这个文件里:
|
|
|
169
171
|
**运行时权限**
|
|
170
172
|
- `files`:只读 `~/.dsh/sessions/` 下的会话 JSONL(成本统计);不写入、不修改任何会话文件
|
|
171
173
|
- `network`:仅请求 DeepSeek 官方端点——`api.deepseek.com`(`GET /user/balance` 余额)与 `api-docs.deepseek.com` 定价页(每 12h 抓取);不走任何第三方代理
|
|
172
|
-
- `commands
|
|
174
|
+
- `commands`:**不执行任何命令**。会话解压改用 Node 内置 zstd(`node:zlib`,需 Node ≥22.15),不再依赖 `zstd` 可执行文件与 `PATH`
|
|
173
175
|
- `credentials`:读取 `DEEPSEEK_API_KEY`(经宿主凭据服务解析),仅宿主进程使用、loopback-only 路由守卫;浏览器不接触密钥
|
|
174
176
|
- 所有 host 路由均绑定 load 回环地址,外部不可达
|
|
175
177
|
|
|
@@ -180,12 +182,26 @@ DSH 的插件配置统一放在这个文件里:
|
|
|
180
182
|
**失败边界**
|
|
181
183
|
- 余额接口失败:面板提示失败信息,保留上次成功快照(不中断)
|
|
182
184
|
- 定价页抓取失败:回退内置 2026-09-10 价目表,`pricingSource` 标记为 `default`(解析成功则为 `synced`)
|
|
183
|
-
-
|
|
185
|
+
- Node 无内置 zstd(<22.15)或会话文件解不出任何帧:返回可读错误提示,而非静默失败
|
|
184
186
|
- 会话文件缺失/损坏:跳过该会话,不影响其他会话统计
|
|
185
187
|
- 所有成本为估算值,实际以官方账单为准
|
|
186
188
|
|
|
187
189
|
## 版本历史
|
|
188
190
|
|
|
191
|
+
### v0.6.0 — 适配 DSH 桌面版,界面与桌面端视觉对齐
|
|
192
|
+
- 🖥️ **适配桌面版(DSH 0.1.7 / DeepSeek Harness.app)**
|
|
193
|
+
- **会话解压不再依赖 `zstd` 命令行**,改用 Node 内置 zstd(`node:zlib`)。桌面版由 GUI 启动,进程 PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`,找不到 Homebrew 的 zstd——此前在桌面端「今日花费 / 最近一次提问」等所有成本档位都会直接报错
|
|
194
|
+
- **按帧解压 v4 会话日志**:日志是 append-only 的,每次 flush 追加一个独立 zstd 帧(实测一份 726 KB 的 v4 日志含 97 帧)。Node 的一次性解压只返回第一帧(258 B)——比报错更危险的是它会静默丢掉 99% 以上的数据。现按帧魔数逐帧解码,v0 / v3 / v4 三种日志与 `zstd -dc` 输出字节级一致
|
|
195
|
+
- **刷新图标按版本解析**:0.1.7 把产品图标改为尺寸中性权重(`IconRefreshOutlineRegular` + `size` 属性),旧名 `IconRefreshOutline14` 已不存在;把它当组件渲染会抛 React #130,而侧边栏槽位对崩溃条目的处理是整条摘掉——表现为「卡片闪一下、一点开就消失」。现按可用名解析并带本地 SVG 兜底
|
|
196
|
+
- **客户端注入声明清空**:`@deepseek-ai/dsh-client-runtime` 从不是真实包(0.1.5 与 0.1.7 都没有),浏览器端只依赖 react 与 shell 的 seed 模块
|
|
197
|
+
- 🎨 **视觉贴合桌面端**
|
|
198
|
+
- 去掉卡片的状态边框(谷时绿 / 峰时琥珀,看着像「被选中」);峰/谷改为金额前的 6px 状态圆点。填充色、12px 圆角、金额 14px 字号均对齐桌面端会话行所用的设计 token
|
|
199
|
+
- 卡片占满侧边栏列:此前被 flex 收缩到 131px 宽并横向溢出 8px
|
|
200
|
+
- **新增侧边栏收起态**:在 56px 图标轨里渲染 36×36 图标按钮(与轨道其它按钮同规格),状态点变为图标角标
|
|
201
|
+
- **弹层改挂 `document.body`**:侧边栏列是 `overflow:hidden`,收起态下弹层此前会被裁到只剩左边一小条;面板宽度跟随卡片(打开时实测,240px 起),并用 `ResizeObserver` 在拖动侧边栏 / 缩放窗口时实时跟随
|
|
202
|
+
- 阴影与焦点环改用桌面端 token(`--dsw-shadow-lv3`、`--dsw-focus-ring-*`)
|
|
203
|
+
- 📄 文档:README 两张截图按新外观重新生成
|
|
204
|
+
|
|
189
205
|
### v0.5.5 — 修复 README 在 npm 包页面上的显示
|
|
190
206
|
- 🐛 **修复**:README 的截图与语言切换此前用相对路径(`docs/screenshot-corner.png`、`README.en.md`)。npm 包页面只渲染 README 正文、不解析仓库内的相对路径,所以在 npm 上两张截图和语言链接都是坏的。现改为绝对 URL:截图走 `raw.githubusercontent.com`,语言切换走 GitHub blob 链接
|
|
191
207
|
- 📦 **范围**:仅文档,代码无变化
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -6,9 +6,44 @@ window.__ModuleLoader__.load({
|
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
8
8
|
let react = require("react");
|
|
9
|
+
let react_dom = require("react-dom");
|
|
9
10
|
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
11
|
+
/**
|
|
12
|
+
* Glyph of the popover's refresh button. DSH 0.1.7 renamed product icons to
|
|
13
|
+
* size-neutral weights (`IconRefreshOutlineRegular` plus a `size` prop —
|
|
14
|
+
* primitives README, decision 2026-09-16); 0.1.5 exported only the
|
|
15
|
+
* artboard-sized `IconRefreshOutline14`. Resolve once and fall back to a
|
|
16
|
+
* local glyph: rendering `undefined` as a component throws React #130, and
|
|
17
|
+
* the slot answers by dropping the whole card.
|
|
18
|
+
*/
|
|
19
|
+
const RefreshGlyph = [
|
|
20
|
+
_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular,
|
|
21
|
+
_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineMedium,
|
|
22
|
+
_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutline14
|
|
23
|
+
].find((glyph) => typeof glyph === "function" || typeof glyph === "object" && glyph !== null);
|
|
24
|
+
/** Refresh glyph element, stroke-matched to the card's own 16px icon. */
|
|
25
|
+
function refreshGlyph() {
|
|
26
|
+
if (RefreshGlyph !== void 0) return (0, react_jsx_runtime.jsx)(RefreshGlyph, {
|
|
27
|
+
size: 14
|
|
28
|
+
});
|
|
29
|
+
return (0, react_jsx_runtime.jsxs)("svg", {
|
|
30
|
+
width: 14,
|
|
31
|
+
height: 14,
|
|
32
|
+
viewBox: "0 0 24 24",
|
|
33
|
+
fill: "none",
|
|
34
|
+
stroke: "currentColor",
|
|
35
|
+
strokeWidth: 1.7,
|
|
36
|
+
strokeLinecap: "round",
|
|
37
|
+
strokeLinejoin: "round",
|
|
38
|
+
children: [(0, react_jsx_runtime.jsx)("polyline", {
|
|
39
|
+
points: "23 4 23 10 17 10"
|
|
40
|
+
}), (0, react_jsx_runtime.jsx)("path", {
|
|
41
|
+
d: "M20.49 15a9 9 0 1 1-2.12-9.36L23 10"
|
|
42
|
+
})]
|
|
43
|
+
});
|
|
44
|
+
}
|
|
10
45
|
//#region lib/types/client/styles.js
|
|
11
|
-
const css = ".dshbw_row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:2px 0}.dshbw_label{display:inline-flex;align-items:center;min-width:0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_val[data-tier=\"sec\"]{font-weight:400;color:var(--dsw-alias-label-secondary)}.dshbw_val[data-tier=\"ter\"]{font-weight:400;color:var(--dsw-alias-label-tertiary)}.dshbw_val[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_val[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.dshbw_err{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:16px;margin-top:6px}.dshbw_link{background:none;border:none;cursor:pointer;color:var(--dsw-alias-state-business-primary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit;text-decoration:none}.dshbw_link:hover{color:var(--dsw-alias-state-business-primary);text-decoration:underline}.dshbw_sideTop{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:
|
|
46
|
+
const css = ".dshbw_row{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:2px 0}.dshbw_label{display:inline-flex;align-items:center;min-width:0}.dshbw_val{font-weight:600;color:var(--dsw-alias-label-primary)}.dshbw_val[data-tier=\"sec\"]{font-weight:400;color:var(--dsw-alias-label-secondary)}.dshbw_val[data-tier=\"ter\"]{font-weight:400;color:var(--dsw-alias-label-tertiary)}.dshbw_val[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary)}.dshbw_val[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.dshbw_err{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:16px;margin-top:6px}.dshbw_link{background:none;border:none;cursor:pointer;color:var(--dsw-alias-state-business-primary);display:inline-flex;align-items:center;gap:4px;padding:2px 6px;border-radius:6px;font-size:12px;line-height:18px;font-family:inherit;text-decoration:none}.dshbw_link:hover{color:var(--dsw-alias-state-business-primary);text-decoration:underline}.dshbw_sideTop{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:14px;line-height:20px}.dshbw_sideAmount{font-weight:600}.dshbw_sideAmount[data-level=\"1\"]{color:var(--dsw-alias-state-warn-primary);font-weight:600}.dshbw_sideAmount[data-level=\"2\"]{color:var(--dsw-alias-state-error-primary)}.dshbw_sideFoot{display:flex;justify-content:space-between;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary)}.dshbw_sideWrap{position:relative;flex:1 1 auto;min-width:0}.dshbw_sideWrap[data-compact=\"true\"]{flex:0 0 auto}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side{width:36px;height:36px;padding:0;margin:0;gap:0;justify-content:center;align-items:center;border-radius:var(--dsw-radius-md,10px);background:transparent;color:var(--dsw-alias-label-primary)}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side:hover{background:var(--dsw-alias-interactive-bg-hover)}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_sideTop{justify-content:center;gap:0}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_sideAmount,.dshbw_sideWrap[data-compact=\"true\"] .dshbw_sideFoot{display:none}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side::after{content:\"\";position:absolute;top:5px;right:5px;width:6px;height:6px;border-radius:50%;display:none}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side[data-peak=\"true\"]::after{display:block;background:var(--dsw-alias-state-warn-primary)}.dshbw_sideWrap[data-compact=\"true\"] .dshbw_side[data-peak=\"false\"]::after{display:block;background:var(--dsw-alias-state-success-primary)}.dshbw_side{position:relative;display:flex;flex-direction:column;gap:4px;width:100%;padding:6px 8px;margin:0 0 2px;border:none;border-radius:12px;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary);cursor:pointer;font-family:inherit;text-align:left;transition:transform 120ms cubic-bezier(0.23,1,0.32,1),background 120ms cubic-bezier(0.23,1,0.32,1)}.dshbw_side:active{transform:scale(.97)}.dshbw_side:hover{background:var(--dsw-alias-interactive-bg-active)}.dshbw_sidePop{position:fixed;z-index:70;box-sizing:border-box;min-width:240px;max-width:calc(100vw - 16px);background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:var(--dsw-shadow-lv3,0 0 1px 0 #0003,0 0 4px 0 #00000005,0 12px 32px 0 #00000014);padding:12px 12px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);animation:dshbw-pop-in 160ms cubic-bezier(0.23,1,0.32,1) backwards}.dshbw_sidePopHead{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}.dshbw_sidePopTitle{font-weight:600}.dshbw_sidePopClose{background:none;border:none;cursor:pointer;color:var(--dsw-alias-label-tertiary);font-size:16px;line-height:1;padding:0 2px;font-family:inherit}.dshbw_sidePopClose:hover{color:var(--dsw-alias-label-primary)}.dshbw_sidePopFoot{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:10px}.dshbw_sidePopRefresh{background:none;border:1px solid var(--dsw-alias-border-l2);cursor:pointer;color:var(--dsw-alias-label-secondary);display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:6px;padding:0;font-family:inherit}.dshbw_sidePopRefresh:hover{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-border-l3);background:var(--dsw-alias-interactive-bg-hover)}.dshbw_sideAmount::before{content:\"\";display:none;width:6px;height:6px;border-radius:50%;margin-right:6px;vertical-align:middle}.dshbw_side[data-peak=\"true\"] .dshbw_sideAmount::before{display:inline-block;background:var(--dsw-alias-state-warn-primary)}.dshbw_side[data-peak=\"false\"] .dshbw_sideAmount::before{display:inline-block;background:var(--dsw-alias-state-success-primary)}.dshbw_peakTag{display:inline-flex;align-items:center;gap:4px;font-size:12px;line-height:16px;padding:1px 7px;border-radius:6px}.dshbw_peakTag[data-peak=\"true\"]{color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-tertiary)}.dshbw_peakTag[data-peak=\"false\"]{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-tertiary)}.dshbw_sessionNote{display:flex;align-items:center;gap:4px;margin-top:6px;padding-top:6px;border-top:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);min-width:0}.dshbw_sessionName{max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dshbw_ico{color:var(--dsw-alias-state-business-primary);display:inline-flex;align-items:center}@keyframes dshbw-pop-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.dshbw_sidePopRefresh,.dshbw_sidePopClose{transition:transform 120ms cubic-bezier(0.23,1,0.32,1),color 120ms cubic-bezier(0.23,1,0.32,1)}.dshbw_sidePopRefresh:active{transform:scale(.92)}.dshbw_sidePopClose:active{transform:scale(.92)}.dshbw_side:focus-visible,.dshbw_link:focus-visible,.dshbw_sidePopClose:focus-visible,.dshbw_sidePopRefresh:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary));outline-offset:-2px}";
|
|
12
47
|
const tagId = "dsh-balance-widget/styles.css";
|
|
13
48
|
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
14
49
|
const tag = document.createElement("style");
|
|
@@ -185,6 +220,14 @@ window.__ModuleLoader__.load({
|
|
|
185
220
|
const [error, setError] = (0, react.useState)(null);
|
|
186
221
|
const [loading, setLoading] = (0, react.useState)(false);
|
|
187
222
|
const [peak, setPeak] = (0, react.useState)(null);
|
|
223
|
+
/**
|
|
224
|
+
* Viewport position of the popover. The panel is portalled into
|
|
225
|
+
* document.body because the sidebar column clips its children
|
|
226
|
+
* (`overflow:hidden`), which cut the panel off at the sidebar edge once
|
|
227
|
+
* the shell collapsed into its 56px rail. Body still carries the theme
|
|
228
|
+
* tokens, so a portalled panel keeps its surface colors.
|
|
229
|
+
*/
|
|
230
|
+
const [popPos, setPopPos] = (0, react.useState)(null);
|
|
188
231
|
// The currently selected session id (the session the user is viewing).
|
|
189
232
|
// This anchors "today · this workspace" to the actual current workspace.
|
|
190
233
|
const currentSessionId = typeof useSessions === "function"
|
|
@@ -242,26 +285,69 @@ window.__ModuleLoader__.load({
|
|
|
242
285
|
const timer = setInterval(() => refresh(false), 60000);
|
|
243
286
|
return () => clearInterval(timer);
|
|
244
287
|
}, [currentSessionId]);
|
|
245
|
-
// Close the popover when clicking outside the card.
|
|
288
|
+
// Close the popover when clicking outside the card or its portalled panel.
|
|
246
289
|
const wrapRef = (0, react.useRef)(null);
|
|
290
|
+
const popRef = (0, react.useRef)(null);
|
|
247
291
|
(0, react.useEffect)(() => {
|
|
248
292
|
if (!open) return;
|
|
249
293
|
const onDocClick = (event) => {
|
|
250
294
|
const el = wrapRef.current;
|
|
251
|
-
|
|
295
|
+
const panel = popRef.current;
|
|
296
|
+
if (el !== null && el.contains(event.target)) return;
|
|
297
|
+
if (panel !== null && panel.contains(event.target)) return;
|
|
298
|
+
setOpen(false);
|
|
252
299
|
};
|
|
253
300
|
document.addEventListener("mousedown", onDocClick);
|
|
254
301
|
return () => document.removeEventListener("mousedown", onDocClick);
|
|
255
302
|
}, [open]);
|
|
303
|
+
/**
|
|
304
|
+
* Anchor the panel just above the card: same width as the card (never
|
|
305
|
+
* narrower than PANEL_MIN_WIDTH), kept inside the viewport.
|
|
306
|
+
*/
|
|
307
|
+
const placePopover = () => {
|
|
308
|
+
const el = wrapRef.current;
|
|
309
|
+
if (el === null) return;
|
|
310
|
+
const rect = el.getBoundingClientRect();
|
|
311
|
+
// The panel matches the card it drops out of, so the two line up
|
|
312
|
+
// whatever width the user gave the sidebar; 240px is the floor for
|
|
313
|
+
// the collapsed rail, where the card is only 36px wide.
|
|
314
|
+
const PANEL_MIN_WIDTH = 240;
|
|
315
|
+
const width = Math.max(PANEL_MIN_WIDTH, Math.round(rect.width));
|
|
316
|
+
setPopPos({
|
|
317
|
+
left: Math.max(8, Math.min(Math.round(rect.left), window.innerWidth - width - 8)),
|
|
318
|
+
bottom: Math.max(8, Math.round(window.innerHeight - rect.top + 8)),
|
|
319
|
+
width: Math.min(width, window.innerWidth - 16)
|
|
320
|
+
});
|
|
321
|
+
};
|
|
322
|
+
// Keep the portalled panel glued to the card while the layout moves:
|
|
323
|
+
// the sidebar can be dragged wider, collapsed or the window resized
|
|
324
|
+
// with the panel still open, and a fixed-position panel cannot follow
|
|
325
|
+
// on its own.
|
|
326
|
+
(0, react.useEffect)(() => {
|
|
327
|
+
if (!open) return;
|
|
328
|
+
const reposition = () => placePopover();
|
|
329
|
+
window.addEventListener("resize", reposition);
|
|
330
|
+
const el = wrapRef.current;
|
|
331
|
+
const observer = typeof ResizeObserver === "function" && el !== null ? new ResizeObserver(reposition) : null;
|
|
332
|
+
if (observer !== null) observer.observe(el);
|
|
333
|
+
return () => {
|
|
334
|
+
window.removeEventListener("resize", reposition);
|
|
335
|
+
if (observer !== null) observer.disconnect();
|
|
336
|
+
};
|
|
337
|
+
}, [open]);
|
|
256
338
|
const level = balance === null ? 0 : balance.total < criticalThreshold ? 2 : balance.total < lowThreshold ? 1 : 0;
|
|
257
339
|
const toggle = async () => {
|
|
258
340
|
const next = !open;
|
|
341
|
+
if (next) placePopover();
|
|
259
342
|
setOpen(next);
|
|
260
343
|
if (next) await refresh(true);
|
|
261
344
|
};
|
|
262
345
|
return (0, react_jsx_runtime.jsx)("div", {
|
|
263
346
|
ref: wrapRef,
|
|
264
347
|
className: styles.sideWrap,
|
|
348
|
+
// The sidebar shell hands the slot a `wide` flag: collapsed, the
|
|
349
|
+
// foot becomes a 56px icon rail, so the card shrinks to one glyph.
|
|
350
|
+
"data-compact": wide === false ? "true" : undefined,
|
|
265
351
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
266
352
|
children: [(0, react_jsx_runtime.jsx)("button", {
|
|
267
353
|
type: "button",
|
|
@@ -292,10 +378,12 @@ window.__ModuleLoader__.load({
|
|
|
292
378
|
})
|
|
293
379
|
})]
|
|
294
380
|
})
|
|
295
|
-
}), open && (0, react_jsx_runtime.jsx)("div", {
|
|
381
|
+
}), open && popPos !== null && (0, react_dom.createPortal)((0, react_jsx_runtime.jsx)("div", {
|
|
382
|
+
ref: popRef,
|
|
296
383
|
className: styles.sidePop,
|
|
297
384
|
"data-peak": peak !== null ? String(peak.active) : undefined,
|
|
298
385
|
role: "dialog",
|
|
386
|
+
style: { left: popPos.left, bottom: popPos.bottom, width: popPos.width },
|
|
299
387
|
children: (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, {
|
|
300
388
|
children: [(0, react_jsx_runtime.jsx)("div", {
|
|
301
389
|
className: styles.sidePopHead,
|
|
@@ -439,12 +527,12 @@ window.__ModuleLoader__.load({
|
|
|
439
527
|
"aria-label": t("refresh.aria"),
|
|
440
528
|
title: t("refresh.title"),
|
|
441
529
|
onClick: () => refresh(true),
|
|
442
|
-
children: (
|
|
530
|
+
children: refreshGlyph()
|
|
443
531
|
})]
|
|
444
532
|
})
|
|
445
533
|
})]
|
|
446
534
|
})
|
|
447
|
-
})]
|
|
535
|
+
}), document.body)]
|
|
448
536
|
})
|
|
449
537
|
});
|
|
450
538
|
}
|
package/lib/index.js
CHANGED
|
@@ -523,51 +523,58 @@ function awaitImportFs() {
|
|
|
523
523
|
return _fs;
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
-
/**
|
|
527
|
-
|
|
526
|
+
/** Zstandard frame magic number (RFC 8878 §3.1.1). */
|
|
527
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
528
528
|
|
|
529
529
|
/**
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
await execFileP("zstd", ["--version"], { timeout: 5000 });
|
|
541
|
-
_zstdAvailable = true;
|
|
542
|
-
} catch {
|
|
543
|
-
_zstdAvailable = false;
|
|
544
|
-
}
|
|
545
|
-
return _zstdAvailable;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* Decompress a .zstd session file to text via the zstd CLI.
|
|
530
|
+
* Decompress a .zstd session file using Node's built-in zstd (>=22.15), which
|
|
531
|
+
* is what DSH itself writes with — no external CLI and no PATH dependency, so
|
|
532
|
+
* this also works when the host was launched by the desktop app.
|
|
533
|
+
*
|
|
534
|
+
* The log is append-only: each flush appends its own complete frame, so a real
|
|
535
|
+
* log holds dozens of frames back to back (a 459 KB 0.1.7 log measured 97).
|
|
536
|
+
* Node's decompressor stops at the end of the first frame and would silently
|
|
537
|
+
* return a few hundred bytes out of a few hundred kilobytes, so frames are
|
|
538
|
+
* located by their magic number and decoded one at a time. A magic sequence
|
|
539
|
+
* occurring inside compressed data fails to decode and is skipped.
|
|
550
540
|
* @returns { ok: true, text } | { ok: false, error }
|
|
551
541
|
*/
|
|
552
542
|
async function decompressSession(path) {
|
|
553
|
-
|
|
543
|
+
const zlib = await import("node:zlib");
|
|
544
|
+
if (typeof zlib.zstdDecompressSync !== "function") {
|
|
554
545
|
return {
|
|
555
546
|
ok: false,
|
|
556
|
-
error: "
|
|
547
|
+
error: "this Node build has no built-in zstd — Node >=22.15 is required to read session files"
|
|
557
548
|
};
|
|
558
549
|
}
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
const execFileP = promisify(execFile);
|
|
550
|
+
const fs = await awaitImportFs();
|
|
551
|
+
let buf;
|
|
562
552
|
try {
|
|
563
|
-
|
|
564
|
-
return { ok: true, text: stdout };
|
|
553
|
+
buf = await fs.promises.readFile(path);
|
|
565
554
|
} catch (error) {
|
|
566
555
|
return {
|
|
567
556
|
ok: false,
|
|
568
|
-
error: `failed to
|
|
557
|
+
error: `failed to read session file: ${error instanceof Error ? error.message : String(error)}`
|
|
569
558
|
};
|
|
570
559
|
}
|
|
560
|
+
/** Decoded frame payloads, concatenated as bytes so a frame boundary that
|
|
561
|
+
* splits a UTF-8 character still decodes correctly. */
|
|
562
|
+
const parts = [];
|
|
563
|
+
let offset = 0;
|
|
564
|
+
while (offset < buf.length) {
|
|
565
|
+
const start = buf.indexOf(ZSTD_MAGIC, offset);
|
|
566
|
+
if (start === -1) break;
|
|
567
|
+
try {
|
|
568
|
+
parts.push(zlib.zstdDecompressSync(buf.subarray(start)));
|
|
569
|
+
offset = start + ZSTD_MAGIC.length;
|
|
570
|
+
} catch {
|
|
571
|
+
offset = start + 1;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (parts.length === 0) {
|
|
575
|
+
return { ok: false, error: `no zstd frame decoded (${buf.length} bytes)` };
|
|
576
|
+
}
|
|
577
|
+
return { ok: true, text: Buffer.concat(parts).toString("utf8") };
|
|
571
578
|
}
|
|
572
579
|
|
|
573
580
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-balance-widget",
|
|
3
|
-
"description": "Balance & cost widget for the
|
|
4
|
-
"version": "0.
|
|
3
|
+
"description": "Balance & cost widget for the DeepSeek Harness GUI (web and desktop app): a sidebar footer card shows the DeepSeek account balance and today's costs; clicking opens a popover with a five-tier cost breakdown (last prompt with session name / today-this-session / today-this-workspace / today-all-workspaces), peak/off-peak status tag, term explanations, and a one-click top-up link. Zero external dependencies — works on Node 24.",
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"files": [
|
|
@@ -25,10 +25,7 @@
|
|
|
25
25
|
"patch": "./cordis.patch.yml"
|
|
26
26
|
},
|
|
27
27
|
"client": {
|
|
28
|
-
"inject": [
|
|
29
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
30
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
31
|
-
],
|
|
28
|
+
"inject": [],
|
|
32
29
|
"platform": "web"
|
|
33
30
|
}
|
|
34
31
|
},
|