@chenlongapps/opencode-token-usage 0.4.8 → 0.4.9
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.md +13 -9
- package/README.zh-CN.md +13 -9
- package/ROADMAP.md +6 -2
- package/dist/click.d.ts +9 -0
- package/dist/click.js +20 -0
- package/dist/context-rpc.d.ts +11 -0
- package/dist/context-rpc.js +6 -0
- package/dist/controller.d.ts +14 -1
- package/dist/controller.js +74 -6
- package/dist/index.js +1 -0
- package/dist/performance.d.ts +8 -3
- package/dist/performance.js +77 -23
- package/dist/runtime-monitor.d.ts +34 -0
- package/dist/runtime-monitor.js +155 -0
- package/dist/runtime.d.ts +15 -0
- package/dist/runtime.js +34 -3
- package/dist/tui.js +250 -203
- package/dist/usage.d.ts +3 -2
- package/dist/usage.js +16 -7
- package/docs/verification.md +112 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -34,17 +34,17 @@ Alternatively, add the package to your project's `opencode.json` or `opencode.js
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> [!NOTE]
|
|
37
|
-
> Requires Node.js 22+ (see the OpenTUI engine note under [Development](#development)). OpenCode 2.0.9 and 2.0.10 have been verified in earlier releases. SDK 2.0.24 integration checks have passed on OpenCode 2.0.11, 2.0.22, 2.0.24, and 2.0.26. Native turn durations were integration-tested on 2.0.26; the other listed host checks predate
|
|
37
|
+
> Requires Node.js 22+ (see the OpenTUI engine note under [Development](#development)). OpenCode 2.0.9 and 2.0.10 have been verified in earlier releases. SDK 2.0.24 integration checks have passed on OpenCode 2.0.11, 2.0.22, 2.0.24, and 2.0.26. Native turn durations and sidebar title clicks were integration-tested on 2.0.26; the other listed host checks predate these additions.
|
|
38
38
|
|
|
39
|
-
Restart OpenCode after installation. The panel appears in the native sidebar when `session.sidebar` is set to `auto` and the terminal is wide enough. OpenCode hides the sidebar in subagent views, so the plugin keeps one live summary line above the composer with Context, Total, Cost, Time, and TPS. Click the line to open the full statistics in a centered dialog. Press Escape or click **esc** to close it; closing the dialog does not interrupt the subagent.
|
|
39
|
+
Restart OpenCode after installation. The panel appears in the native sidebar when `session.sidebar` is set to `auto` and the terminal is wide enough. Click its **Token Usage** title to open the detailed usage dialog. OpenCode hides the sidebar in subagent views, so the plugin keeps one live summary line above the composer with Context, Total, Cost, Time, and TPS. Click the line to open the full statistics in a centered dialog. Press Escape or click **esc** to close it; closing the dialog does not interrupt the subagent.
|
|
40
40
|
|
|
41
41
|
For remote sessions, add the package name to `plugins` in your local `~/.config/opencode/cli.json` to load only the terminal entry point. The configuration path follows `XDG_CONFIG_HOME`.
|
|
42
42
|
|
|
43
43
|
### Detailed usage
|
|
44
44
|
|
|
45
|
-
Enter `/usage` in a session to open
|
|
45
|
+
Enter `/usage` in a session or left-click the sidebar's **Token Usage** title to open the same centered native dialog with the session tree's token totals and estimated cost by recorded model. It opens in compact mode. Scroll with ↑/↓, Page Up/Down, Home/End, press `d` to switch between compact and detailed numbers and reveal model rates, and close with Escape or **esc**. Neither entry sends a prompt to the model. Other sidebar rows remain selectable; dragging to select text does not open the dialog.
|
|
46
46
|
|
|
47
|
-
The subagent picker keeps the summary in the form `Token Usage · Context … · Total … · Cost … · Time … · TPS …`. Time sums the viewed subagent's own native per-turn response durations; token totals and cost still cover the whole tree. Narrow terminals omit lower-priority fields, keeping Time visible without adding a second line. Missing values remain unavailable rather than becoming zero, and the line does not advertise a keyboard shortcut. Clicking it opens a smaller dialog with the sidebar's exact rows (including Steps, TPS, TTFT and Run Time) without reserving space for the full panel while closed. Run Time is the last row in both the sidebar and this dialog.
|
|
47
|
+
The subagent picker keeps the summary in the form `Token Usage · Context … · Total … · Cost … · Time … · TPS …`. Time sums the viewed subagent's own native per-turn response durations; token totals and cost still cover the whole tree. Narrow terminals omit lower-priority fields, keeping Time visible when present without adding a second line. Missing values remain unavailable rather than becoming zero, and the line does not advertise a keyboard shortcut. Clicking it opens a smaller dialog with the sidebar's exact rows (including Steps, TPS, TTFT and Run Time) without reserving space for the full panel while closed. When shown, Run Time is the last row in both the sidebar and this dialog.
|
|
48
48
|
|
|
49
49
|
The dialog is split into five sections with separate statistics:
|
|
50
50
|
|
|
@@ -76,8 +76,8 @@ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. I
|
|
|
76
76
|
| `Context` | Latest context usage after the most recent completed compaction in the viewed session; not aggregated across the subtree |
|
|
77
77
|
| `Steps` | Assistant message count across the session tree, including subagents; follows OpenCode's own stats definition, so compaction and user messages are not steps |
|
|
78
78
|
| `Est. Cost` | Estimated cost across the tree, pricing each assistant and compaction call with its actual model |
|
|
79
|
-
| `Run Time` |
|
|
80
|
-
| `TPS` |
|
|
79
|
+
| `Run Time` | The viewed session's cumulative native per-turn response duration; while running, includes a display-only estimate of the current turn's elapsed time marked with `~`; never adds child-session durations |
|
|
80
|
+
| `TPS` | Provider-reported average throughput for `Output + Reasoning` across the tree. While streaming, a valid recent observable text, reasoning-summary and tool-input estimate (UTF-8 bytes / 4) takes precedence and is marked with `~`. It needs at least 500 ms of observation after an initial baseline batch, excluding pre-first-token waiting. APIs such as OpenAI Responses do not expose complete hidden reasoning tokens in real time, so live values cannot represent hidden reasoning throughput. Completion restores provider-reported throughput without `~` |
|
|
81
81
|
| `TTFT` | Average time to first token across measurable assistant steps in the tree |
|
|
82
82
|
|
|
83
83
|
#### Behavior
|
|
@@ -87,12 +87,16 @@ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. I
|
|
|
87
87
|
- `Context` only searches messages after the most recent compaction with `status === "completed"` and is hidden when reliable usage or a model context limit is unavailable.
|
|
88
88
|
- `Steps` counts every assistant message in the tree, whether or not it reported usage, and reuses the fork-copy de-duplication so inherited history is never counted twice.
|
|
89
89
|
- `Run Time` follows OpenCode 2.0.26's native response footer: assistant completion time minus the first user/synthetic input's creation time after the previous idle marker. Older histories without idle markers use the nearest input; without any input, it uses the assistant's creation time. Each turn contributes only its final completed response duration, not the overlapping duration of every assistant step. Model calls, tools, retries and foreground waits within that span are included; idle time between turns is not. Waiting for a subagent is already part of the parent's span and is never added separately.
|
|
90
|
-
- `Run Time`
|
|
90
|
+
- While the viewed session is running, `Run Time` estimates **elapsed time so far**, not final or remaining time, and prefixes the cumulative display with `~` (for example, `~42.5s`). A shared 500 ms clock replaces the current turn's already-counted duration rather than adding an overlapping steer/retry duration. Clock ticks only update memory: history reads remain event-driven. One optional read-only server-plugin clock RPC at startup/reconnection supplies a server-time anchor, with timestamped live events as a fallback; local monotonic time advances it without assuming that a remote server shares the client's clock. Opening an already-running session restores the turn's start from history. Without a trusted anchor or valid timing, native totals remain unchanged.
|
|
91
|
+
- Completion, failure or interruption stops projection; reported native completion timing replaces the estimate and removes `~`, so a small downward correction is possible. Only native completed durations enter the historical subtotal. Totals use unrounded milliseconds before formatting (milliseconds below one second, one decimal below one minute, then minutes/hours), so they need not equal the sum of rounded footer labels. The existing message-history read restores totals after restarts, including sessions predating installation; no experimental execution-log API or server plugin is required. Missing historical timing data displays `—`, not zero. Read failures freeze the last displayed value and mark it `Not updated` (`stale` in the short summary).
|
|
92
|
+
- `Run Time` / `Time` shows the duration and its estimate/freshness markers, without running-status text or animation. An idle parent's time never advances just because a child is active. Closing or switching views cleans up their estimates, and the shared elapsed-time ticker stops when no view needs it. Unavailable/stale timing keeps its own marker.
|
|
93
|
+
- A confirmed zero `Run Time` / `Time` is hidden in the sidebar, subagent summary/dialog and both `/usage` modes, without leaving an empty row or field. A nonzero live estimate makes the first turn visible even before any response has completed. Nonzero millisecond durations, unavailable values and stale timing remain visible.
|
|
91
94
|
- Runtime follows retained history: agent/model switches and compaction preserve past turns; fork copies never contribute to the fork's own time. A staged revert does not remove turns, but a committed revert deleting messages also removes their durations. Deleted historical timing cannot be reconstructed.
|
|
92
95
|
- `Est. Cost` prices every message with its recorded model. A complete non-zero price resolved by OpenCode takes precedence. If OpenCode reports a complete zero price, a complete first-party snapshot price overrides it; incomplete prices fall back for the whole message without mixing rates.
|
|
93
96
|
- The checked-in fallback snapshot is generated from [models.dev](https://models.dev/api.json) using only reviewed first-party provider/model families; a small set of manufacturer-verified exceptions is kept separately. It covers priced text models, without downloading prices while the plugin runs. Gateway models match exact manufacturer IDs, documented aliases, and known wrappers; only a terminal `-free` or `:free` can be removed for a second exact lookup.
|
|
94
97
|
- Confirmed free usage displays `$0.00`; positive estimates below `$0.001` display `<$0.001`, estimates through and including `$0.01` use three decimal places, and larger estimates use two. Unavailable prices display `—`; known subtotals with unpriced messages are marked `partial`. The snapshot excludes gateway markups, regional premiums, unlisted discounts, non-text billing, tool fees, and taxes, so Est. Cost is not a provider bill. See [price sources and limitations](docs/pricing.md).
|
|
95
|
-
- Live `TPS`
|
|
98
|
+
- Live `TPS` uses a ~2s sliding window, batches deliveries within 100 ms and requires a span of at least 500 ms. The first batch is only a baseline: its bytes are not counted as output generated after its timestamp. Smoothing follows elapsed time rather than delta count. After 2s without a new delta, a shared in-memory timeout expires the estimate without reading history; historical TPS takes over, or the row is hidden if unavailable. Resuming output after expiry starts a fresh baseline and smoothing state.
|
|
99
|
+
- Live TPS measures observable delivery, not the model's internal token-generation clock. Responses summary bursts, done-only content and transport buffering can still affect estimates; complete hidden reasoning cannot be reconstructed. Without `~`, TPS is `Σ(Output + Reasoning) ÷ Σ(time.streamed − time.created)` in seconds across valid assistant samples in the tree, not just the last request. This historical denominator includes pre-first-token waiting, so it is not the same metric as the recent live estimate. No model-specific multipliers, hard TPS caps or hidden-reasoning guesses are applied.
|
|
96
100
|
- Initial read failures display `Unavailable`. Later failures retain the last complete snapshot, display `Not updated`, and retry automatically.
|
|
97
101
|
|
|
98
102
|
### Development
|
|
@@ -113,7 +117,7 @@ Failed validation never bumps the version. An unfinished tagged price release is
|
|
|
113
117
|
|
|
114
118
|
For a manual refresh, run `npm run prices:update` in a networked environment, review the generated diff and exceptions, then run the checks above. Builds and plugin refreshes do not contact models.dev.
|
|
115
119
|
|
|
116
|
-
`test:smoke` packages the real artifact and validates loading, refreshes, `/usage`, subagent aggregation, per-message pricing, first-party price fallback, model switching, TPS, TTFT, and session-local native turn durations (unfinished responses, idle gaps, interruption, message-history restoration, forks and narrow child views) against an isolated OpenCode instance and
|
|
120
|
+
`test:smoke` packages the real artifact and validates loading, refreshes, `/usage`, sidebar title clicks, subagent aggregation, per-message pricing, first-party price fallback, model switching, TPS, TTFT, Responses summary bursts/done-only summaries/quiet expiry, and session-local native turn durations (unfinished responses, idle gaps, interruption, message-history restoration, forks and narrow child views) against an isolated OpenCode instance and local mock providers. It requires Python 3, an available local port, and npm network access. It never modifies your existing OpenCode configuration or calls paid models.
|
|
117
121
|
|
|
118
122
|
By default, smoke uses `opencode` from `PATH`. Stable OpenCode 2 versions from 2.0.9 onward can run; a host not yet verified with the current SDK prints a warning and continues through the actual compatibility checks. Passing the version check alone does not establish compatibility. The SDK dependencies remain pinned independently of your local CLI updates.
|
|
119
123
|
|
package/README.zh-CN.md
CHANGED
|
@@ -34,17 +34,17 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> [!NOTE]
|
|
37
|
-
> 需要 Node.js 22+(见[开发](#开发)中的 OpenTUI 引擎说明)。此前发布已验证 OpenCode 2.0.9 和 2.0.10;SDK 2.0.24 集成检查已在 OpenCode 2.0.11、2.0.22、2.0.24 和 2.0.26
|
|
37
|
+
> 需要 Node.js 22+(见[开发](#开发)中的 OpenTUI 引擎说明)。此前发布已验证 OpenCode 2.0.9 和 2.0.10;SDK 2.0.24 集成检查已在 OpenCode 2.0.11、2.0.22、2.0.24 和 2.0.26 上通过。原生对话耗时与侧边栏标题点击的集成验证使用 2.0.26,其余列出的宿主检查早于这些功能加入。
|
|
38
38
|
|
|
39
|
-
安装后重启 OpenCode。终端足够宽且 `session.sidebar` 设为 `auto`
|
|
39
|
+
安装后重启 OpenCode。终端足够宽且 `session.sidebar` 设为 `auto` 时,面板会显示在原生侧边栏中。点击面板的 **Token Usage** 标题可打开用量详情弹窗。OpenCode 在子代理视图中隐藏侧边栏,因此插件会在输入区上方保留一行实时摘要,显示 Context、Total、Cost、Time 和 TPS。点击摘要可在居中弹窗中查看完整统计;按 Escape 或点击 **esc** 即可关闭,关闭弹窗不会中断子代理。
|
|
40
40
|
|
|
41
41
|
连接远程服务器时,可将包名加入本机 `~/.config/opencode/cli.json` 的 `plugins`,仅加载终端入口;配置路径遵循 `XDG_CONFIG_HOME`。
|
|
42
42
|
|
|
43
43
|
### 详细用量
|
|
44
44
|
|
|
45
|
-
在会话中输入 `/usage
|
|
45
|
+
在会话中输入 `/usage`,或左键点击侧边栏的 **Token Usage** 标题,可打开同一个居中原生弹窗,查看当前会话树的 token 总量,以及按消息实际模型汇总的估算费用。弹窗默认紧凑模式。使用 ↑/↓、Page Up/Down、Home/End 滚动,按 `d` 切换紧凑/详细数字并查看模型费率,按 Escape 或点击 **esc** 关闭。两个入口均不会向模型发送消息。侧边栏其他统计行仍可选择、复制,拖选文字不会打开弹窗。
|
|
46
46
|
|
|
47
|
-
子代理视图中的摘要格式为 `Token Usage · Context … · Total … · Cost … · Time … · TPS …`。Time 仅累计被查看子代理自身每轮原生显示的对话耗时,token
|
|
47
|
+
子代理视图中的摘要格式为 `Token Usage · Context … · Total … · Cost … · Time … · TPS …`。Time 仅累计被查看子代理自身每轮原生显示的对话耗时,token 总量和费用仍统计整棵树。窄终端会省略优先级较低的字段,在 Time 未隐藏时优先保留它,且不增加第二行。缺失数据仍显示不可用,不会被当作零,摘要也不提示快捷键。点击后打开较小的弹窗,按侧边栏原有行序展示精确数值(包括 Steps、TPS、TTFT 和 Run Time);关闭时不会为完整面板预留高度。Run Time 显示时,在侧边栏及该弹窗中均为最后一行。
|
|
48
48
|
|
|
49
49
|
弹窗分为五个区域,统计口径互相独立:
|
|
50
50
|
|
|
@@ -76,8 +76,8 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
76
76
|
| `Context` | 当前会话最后一次已完成压缩后的最新上下文用量,不随子树累计 |
|
|
77
77
|
| `Steps` | 全树(含子代理)的 assistant 消息数,口径与 OpenCode 自带统计一致,compaction 与用户消息不计为 step |
|
|
78
78
|
| `Est. Cost` | 按每条 assistant 与 compaction 消息实际模型估算的全树费用 |
|
|
79
|
-
| `Run Time` |
|
|
80
|
-
| `TPS` |
|
|
79
|
+
| `Run Time` | 被查看会话自身的累计原生对话耗时;运行中包含仅用于显示的当前轮已耗时估算,以 `~` 标记;不额外叠加子会话耗时 |
|
|
80
|
+
| `TPS` | 服务端已上报的全树 `Output + Reasoning` 平均吞吐。流式进行中时,优先显示有效的近期可观察文本、reasoning summary 和工具输入估算(UTF-8 字节数 / 4),以 `~` 标记;首批增量仅作基线,之后至少观察 500ms,不计首 token 前等待时间。OpenAI Responses 等 API 不会实时暴露完整的隐藏 reasoning token,因此实时值不能表示隐藏推理吞吐。完成后恢复不带 `~` 的服务端上报吞吐 |
|
|
81
81
|
| `TTFT` | 全树中可测 assistant step 的平均首 token 时间 |
|
|
82
82
|
|
|
83
83
|
#### 行为
|
|
@@ -87,12 +87,16 @@ opencode plugin add @chenlongapps/opencode-token-usage
|
|
|
87
87
|
- `Context` 仅搜索最后一次 `status === "completed"` 的 compaction 之后;没有可靠用量或模型上限时隐藏。
|
|
88
88
|
- `Steps` 统计树中全部 assistant 消息,无论是否已上报用量;复用分叉副本去重,继承历史不会重复计数。
|
|
89
89
|
- `Run Time` 沿用 OpenCode 2.0.26 的原生回答页脚口径:assistant 完成时间减去上一条 idle 标记之后首个 user/synthetic 输入的创建时间。没有 idle 标记的旧历史使用最近一条输入;没有输入则回退到 assistant 自身的创建时间。每轮只计最终已完成回答的耗时,不把各条 assistant step 的重叠时长重复相加。包含跨度内的模型调用、工具、重试和前台等待,不含轮次之间的空闲;父会话等待子代理的时间已在父会话跨度内,不再额外叠加子代理耗时。
|
|
90
|
-
-
|
|
90
|
+
- 被查看会话运行中时,`Run Time` 估算的是**截至当前已经过去的时间**,不是最终耗时或剩余时间,累计显示值带 `~`(例如 `~42.5s`)。共享的 500ms 时钟替换当前轮已经计入的时长,不额外叠加重叠的 steer/retry 耗时。时钟 tick 只更新内存,历史读取仍由事件触发。启动/重连时通过一次可选的服务端插件只读时间 RPC 取得服务端锚点,带时间戳的实时事件可作为回退;用本地单调时钟推进,不假定远程服务端与客户端时钟一致。中途打开正在运行的会话时,从历史恢复当前轮起点。没有可信锚点或有效时间时,保留原生累计值。
|
|
91
|
+
- 完成、失败或中断时停止估算;上报的原生完成耗时替换估算值并移除 `~`,因此允许小幅向下校正。历史累计只包含原生已完成耗时。先累加未取整的毫秒值,再格式化(不足一秒显示毫秒,不足一分钟的秒值保留一位小数,其后显示分/小时),因此不一定等于相加各轮已取整的页脚标签。复用现有消息历史读取,重启后可恢复,也适用于安装之前的会话;不依赖实验性执行日志 API 或服务端插件。历史计时数据缺失时显示 `—`,不会显示伪零。读取失败冻结上次显示值并标注 `Not updated`(短摘要中为 `stale`)。
|
|
92
|
+
- `Run Time`/`Time` 仅显示耗时及估算/未更新标记,不附加运行状态文本或动画。已空闲父会话的耗时不会因子代理活跃而增长。关闭视图或切换会话时清理相应估算,无视图需要估算时停止共享耗时 ticker。不可用/未更新的耗时仍保留各自标记。
|
|
93
|
+
- 正常状态下确认累计值为零时,侧边栏、子代理摘要/浮窗及 `/usage` 紧凑/详细模式均隐藏整个 `Run Time`/`Time` 项,不留空行或空字段。当前轮实时估算非零时,首轮回答尚未完成也会显示时间。非零毫秒耗时、不可用值和未更新的耗时仍保留显示。
|
|
91
94
|
- 耗时随保留历史变化:切换 agent/模型与压缩保留过去轮次;分叉继承副本不计入分叉自身耗时。暂存回退不会删除轮次,但已提交回退删除消息后,也会移除这些消息对应的耗时;已删除的历史计时无法恢复。
|
|
92
95
|
- `Est. Cost` 按每条消息记录的实际模型分别计算。OpenCode 当前解析出的完整非零价格优先;若 OpenCode 给出完整零价,且内置价格快照可以完整计价,则采用快照价格。价格不完整时整条消息回退,不混用两套费率。
|
|
93
96
|
- 内置回退快照从 [models.dev](https://models.dev/api.json) 生成,只采用审核过的原厂 Provider 与自家模型系列;少量经原厂核实的例外单独维护。快照覆盖有价格的文本模型,插件运行时不下载价格。网关模型通过精确原厂 ID、明确别名和已知包装格式匹配;只有终尾 `-free` 或 `:free` 会在再次精确查找前移除。
|
|
94
97
|
- 确认免费时显示 `$0.00`;正费用低于 `$0.001` 时显示 `<$0.001`,不超过 `$0.01` 的费用保留三位小数,更高费用保留两位。价格不可用时显示 `—`;只有部分消息可计算时在已知小计后标注 `partial`。快照不含网关加价、区域溢价、未列明的折扣、非文本计费、工具费和税费;Est. Cost 是估算而非账单。覆盖范围、来源和限制见[价格来源与限制](docs/pricing.md)。
|
|
95
|
-
-
|
|
98
|
+
- 实时 `TPS` 使用约 2 秒滑动窗口,合并 100ms 内的增量,要求至少 500ms 的有效跨度。首批增量仅作基线,其字节不计为该时间戳之后生成的输出;平滑按经过时间而非 delta 条数推进。2 秒没有新增量时,共享的内存超时任务使估算过期,不读取历史;回退到历史 TPS,没有可用历史值则隐藏该行。过期后恢复输出时,重新建立基线与平滑状态。
|
|
99
|
+
- 实时 TPS 测量可观察的内容交付,而非模型内部生成 token 的时钟。Responses 摘要突发、仅在 done 中返回的内容和传输缓冲仍可能影响估算,不能还原完整隐藏推理。不带 `~` 时,TPS 为全树有效 assistant 样本的 `Σ(Output + Reasoning) ÷ Σ(time.streamed − time.created)`(换算为秒),不是仅取最近请求;该历史分母包含首 token 前等待,与近期实时估算口径不同。不做模型专属倍率、TPS 硬上限或隐藏 reasoning token 猜测。
|
|
96
100
|
- 首次读取失败显示 `Unavailable`;后续失败保留上次完整快照、标注 `Not updated` 并自动重试。
|
|
97
101
|
|
|
98
102
|
### 开发
|
|
@@ -113,7 +117,7 @@ OpenCode SDK 包精确锁定为 `2.0.24`,OpenTUI 为 `0.5.14`。Node.js 22 下
|
|
|
113
117
|
|
|
114
118
|
手动更新时,在联网的维护环境运行 `npm run prices:update`,审阅生成文件及例外差异,再执行上述检查。构建和插件刷新不会请求 models.dev。
|
|
115
119
|
|
|
116
|
-
`test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage
|
|
120
|
+
`test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage`、侧边栏标题点击、子代理累计、逐消息计价、原厂价格补全、模型切换、TPS、TTFT、Responses 摘要突发/done-only 摘要/静默过期,以及会话自身的原生对话耗时(未完成回答、空闲、中断、消息历史恢复、分叉和窄终端子代理视图)。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
|
|
117
121
|
|
|
118
122
|
默认使用 `PATH` 中的 `opencode`。OpenCode 2.0.9 及之后的稳定版 2.x 可以运行;尚未与当前 SDK 验证的宿主会输出警告,然后继续实际兼容性检查。仅通过版本检查不代表兼容性已验证。SDK 依赖仍独立锁定,不随本机 CLI 更新而自动升级。
|
|
119
123
|
|
package/ROADMAP.md
CHANGED
|
@@ -108,14 +108,18 @@ Steps 1,234
|
|
|
108
108
|
## 开发中 — 会话累计对话耗时(未发布)
|
|
109
109
|
|
|
110
110
|
* [x] 按 OpenCode 2.0.26 原生回答页脚口径累计当前查看会话的全部保留轮次,每轮只取最终已完成回答
|
|
111
|
-
* [x]
|
|
111
|
+
* [x] 不计轮次之间的空闲,不额外叠加子代理耗时,未完成回答不写入原生累计值
|
|
112
112
|
* [x] 复用完整消息历史,重开会话和插件重启后恢复累计值,不依赖执行日志 API
|
|
113
113
|
* [x] 侧边栏 `Run Time`、子代理单行 `Time`、`/usage` 的 `Run Time (this session)` 三处显示
|
|
114
|
+
* [x] 移除各耗时入口的 `Running...` 文本及点号动画,保留 `~` 实时估算、未更新标记与零值隐藏
|
|
115
|
+
* [x] 运行中以 `~` 显示当前轮已耗时估算,首轮非零估算可见;共享 500ms 单调时钟,不定时读取 API,结束后校正为原生值
|
|
114
116
|
* [x] 时间缺失显示不可用,读取失败保留旧值并标注未更新;分叉副本不继承耗时,已提交回退随删除历史扣除
|
|
115
|
-
* [x]
|
|
117
|
+
* [x] 移除动画后通过类型检查、205 项测试、构建及真实 OpenCode 2.0.26 完整打包集成复验
|
|
116
118
|
|
|
117
119
|
## 后续评估
|
|
118
120
|
|
|
119
121
|
* [x] 首次 npm 发布:`@chenlongapps/opencode-token-usage@0.2.1`
|
|
120
122
|
|
|
121
123
|
## 待解决问题
|
|
124
|
+
|
|
125
|
+
- [x] TPS 显示机制修改:100ms 突发合并、首批基线、500ms 最小跨度、时间加权平滑与静默过期;209 项测试及真实 OpenCode 2.0.26 本地 Responses SSE 打包集成验证通过
|
package/dist/click.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MouseEvent } from "@opentui/core";
|
|
2
|
+
type ClickEvent = Pick<MouseEvent, "button" | "x" | "y" | "stopPropagation">;
|
|
3
|
+
/** Text mouse-up can be marked isDragging even for a plain click by OpenTUI selection. */
|
|
4
|
+
export declare function createClickHandlers(open: () => void, hasSelection: () => boolean): {
|
|
5
|
+
onMouseDown(event: ClickEvent): void;
|
|
6
|
+
onMouseDrag(): void;
|
|
7
|
+
onMouseUp(event: ClickEvent): void;
|
|
8
|
+
};
|
|
9
|
+
export {};
|
package/dist/click.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Text mouse-up can be marked isDragging even for a plain click by OpenTUI selection. */
|
|
2
|
+
export function createClickHandlers(open, hasSelection) {
|
|
3
|
+
let pressed;
|
|
4
|
+
return {
|
|
5
|
+
onMouseDown(event) {
|
|
6
|
+
pressed = event.button === 0 ? { x: event.x, y: event.y } : undefined;
|
|
7
|
+
},
|
|
8
|
+
onMouseDrag() {
|
|
9
|
+
pressed = undefined;
|
|
10
|
+
},
|
|
11
|
+
onMouseUp(event) {
|
|
12
|
+
const start = pressed;
|
|
13
|
+
pressed = undefined;
|
|
14
|
+
if (event.button !== 0 || !start || event.x !== start.x || event.y !== start.y || hasSelection())
|
|
15
|
+
return;
|
|
16
|
+
event.stopPropagation();
|
|
17
|
+
open();
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
package/dist/context-rpc.d.ts
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
export declare const ContextSourceRpc: {
|
|
2
2
|
readonly id: "opencode-token-usage.context";
|
|
3
3
|
readonly methods: {
|
|
4
|
+
readonly clock: {
|
|
5
|
+
readonly input: {
|
|
6
|
+
readonly type: "object";
|
|
7
|
+
readonly properties: {};
|
|
8
|
+
readonly additionalProperties: false;
|
|
9
|
+
};
|
|
10
|
+
readonly output: {
|
|
11
|
+
readonly type: "number";
|
|
12
|
+
readonly minimum: 0;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
4
15
|
readonly latest: {
|
|
5
16
|
readonly input: {
|
|
6
17
|
readonly type: "object";
|
package/dist/context-rpc.js
CHANGED
|
@@ -3,6 +3,12 @@ const numbers = ["Messages", "System Tools", "System Prompt", "Skills", "MCP Too
|
|
|
3
3
|
export const ContextSourceRpc = Rpc.define({
|
|
4
4
|
id: "opencode-token-usage.context",
|
|
5
5
|
methods: {
|
|
6
|
+
// A read-only bootstrap for a quiet, already-running session. OpenCode
|
|
7
|
+
// 2.0.26's synthetic server.connected envelope has no created timestamp.
|
|
8
|
+
clock: {
|
|
9
|
+
input: { type: "object", properties: {}, additionalProperties: false },
|
|
10
|
+
output: { type: "number", minimum: 0 },
|
|
11
|
+
},
|
|
6
12
|
latest: {
|
|
7
13
|
input: {
|
|
8
14
|
type: "object", properties: { sessionID: { type: "string" } }, required: ["sessionID"], additionalProperties: false,
|
package/dist/controller.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { UsageSource } from "./source.js";
|
|
|
2
2
|
import { PerformanceMonitor } from "./performance.js";
|
|
3
3
|
import type { PerformanceSummary } from "./performance.js";
|
|
4
4
|
import type { RuntimeSummary } from "./runtime.js";
|
|
5
|
+
import { RuntimeMonitor } from "./runtime-monitor.js";
|
|
5
6
|
import type { ContextSources } from "./context-sources.js";
|
|
6
7
|
import type { ContextUsage, ModelCost, RequestDetails, Summary } from "./usage.js";
|
|
7
8
|
export interface UsageState {
|
|
@@ -51,12 +52,24 @@ export declare class UsageController {
|
|
|
51
52
|
private readonly unsubscribePerformance;
|
|
52
53
|
private readonly performance;
|
|
53
54
|
private readonly ownsPerformance;
|
|
54
|
-
|
|
55
|
+
private readonly runtime;
|
|
56
|
+
private readonly ownsRuntime;
|
|
57
|
+
private readonly unsubscribeRuntime;
|
|
58
|
+
private stopRuntimeTick;
|
|
59
|
+
private preparedRuntime;
|
|
60
|
+
private running;
|
|
61
|
+
private runtimeFresh;
|
|
62
|
+
constructor(source: UsageSource, subscribe: Subscribe, publish: (state: UsageState) => void, delay?: number, retryDelay?: number, performanceDelay?: number, performance?: PerformanceMonitor, detailed?: boolean, runtime?: RuntimeMonitor);
|
|
55
63
|
select(sessionID: string): void;
|
|
56
64
|
refresh(): void;
|
|
65
|
+
/** The TUI supplies the viewed session's reactive native status, never its children's status. */
|
|
66
|
+
setRunning(running: boolean): void;
|
|
57
67
|
private schedule;
|
|
58
68
|
dispose(): void;
|
|
59
69
|
private update;
|
|
70
|
+
private stopRuntime;
|
|
71
|
+
private runtimeSummary;
|
|
72
|
+
private refreshRuntime;
|
|
60
73
|
private refreshPerformance;
|
|
61
74
|
private run;
|
|
62
75
|
}
|
package/dist/controller.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
|
|
2
2
|
import { PerformanceMonitor, preparePerformance } from "./performance.js";
|
|
3
|
-
import {
|
|
3
|
+
import { prepareRuntime, projectRuntime } from "./runtime.js";
|
|
4
|
+
import { RuntimeMonitor } from "./runtime-monitor.js";
|
|
4
5
|
import { contextUsage, requestDetails, summarize, summarizeModels } from "./usage.js";
|
|
5
6
|
const sessionEvents = new Set([
|
|
6
7
|
"session.created", "session.deleted", "session.forked", "session.moved",
|
|
@@ -12,8 +13,9 @@ const sessionEvents = new Set([
|
|
|
12
13
|
]);
|
|
13
14
|
const globalEvents = new Set([
|
|
14
15
|
"server.connected", "model.updated", "provider.updated", "models-dev.refreshed",
|
|
15
|
-
"config.updated", "location.shutdown",
|
|
16
|
+
"config.updated", "location.shutdown", "global.disposed",
|
|
16
17
|
]);
|
|
18
|
+
const runtimeEvents = new Set(["session.step.started", "session.inbox.delivered", "session.retry.scheduled", "session.compaction.started"]);
|
|
17
19
|
/** Owns snapshot refreshes and throttled in-memory performance updates for one mounted view. */
|
|
18
20
|
export class UsageController {
|
|
19
21
|
source;
|
|
@@ -40,7 +42,14 @@ export class UsageController {
|
|
|
40
42
|
unsubscribePerformance;
|
|
41
43
|
performance;
|
|
42
44
|
ownsPerformance;
|
|
43
|
-
|
|
45
|
+
runtime;
|
|
46
|
+
ownsRuntime;
|
|
47
|
+
unsubscribeRuntime;
|
|
48
|
+
stopRuntimeTick;
|
|
49
|
+
preparedRuntime;
|
|
50
|
+
running = false;
|
|
51
|
+
runtimeFresh = false;
|
|
52
|
+
constructor(source, subscribe, publish, delay = 80, retryDelay = 3_000, performanceDelay = 100, performance, detailed = false, runtime) {
|
|
44
53
|
this.source = source;
|
|
45
54
|
this.publish = publish;
|
|
46
55
|
this.delay = delay;
|
|
@@ -53,11 +62,22 @@ export class UsageController {
|
|
|
53
62
|
if (this.sessions.has(sessionID))
|
|
54
63
|
this.refreshPerformance();
|
|
55
64
|
});
|
|
65
|
+
this.ownsRuntime = runtime === undefined;
|
|
66
|
+
this.runtime = runtime ?? new RuntimeMonitor(subscribe);
|
|
67
|
+
this.unsubscribeRuntime = this.runtime.listen(sessionID => {
|
|
68
|
+
if (!sessionID || sessionID === this.sessionID)
|
|
69
|
+
this.refreshRuntime();
|
|
70
|
+
});
|
|
56
71
|
this.unsubscribe = subscribe(event => {
|
|
57
72
|
const data = (event.data && typeof event.data === "object" ? event.data : {});
|
|
58
|
-
if (globalEvents.has(event.type))
|
|
73
|
+
if (globalEvents.has(event.type)) {
|
|
74
|
+
if (["server.connected", "location.shutdown", "global.disposed"].includes(event.type)) {
|
|
75
|
+
this.runtimeFresh = false;
|
|
76
|
+
this.stopRuntime();
|
|
77
|
+
}
|
|
59
78
|
return this.refresh();
|
|
60
|
-
|
|
79
|
+
}
|
|
80
|
+
if (!sessionEvents.has(event.type) && !(this.running && data.sessionID === this.sessionID && runtimeEvents.has(event.type)))
|
|
61
81
|
return;
|
|
62
82
|
if (event.type === "session.created") {
|
|
63
83
|
if (data.sessionID && data.parentID && (this.sessions.has(data.parentID) || data.parentID === this.sessionID)) {
|
|
@@ -87,11 +107,14 @@ export class UsageController {
|
|
|
87
107
|
clearTimeout(this.timer);
|
|
88
108
|
clearTimeout(this.retry);
|
|
89
109
|
clearTimeout(this.performanceTimer);
|
|
110
|
+
this.stopRuntime();
|
|
90
111
|
this.timer = undefined;
|
|
91
112
|
this.performanceTimer = undefined;
|
|
92
113
|
this.pending = false;
|
|
93
114
|
this.snapshot = undefined;
|
|
94
115
|
this.prepared = undefined;
|
|
116
|
+
this.preparedRuntime = undefined;
|
|
117
|
+
this.running = this.runtimeFresh = false;
|
|
95
118
|
this.sessions.clear();
|
|
96
119
|
this.changed.clear();
|
|
97
120
|
this.fullScan = true;
|
|
@@ -102,6 +125,17 @@ export class UsageController {
|
|
|
102
125
|
refresh() {
|
|
103
126
|
this.schedule(true);
|
|
104
127
|
}
|
|
128
|
+
/** The TUI supplies the viewed session's reactive native status, never its children's status. */
|
|
129
|
+
setRunning(running) {
|
|
130
|
+
if (this.disposed || running === this.running)
|
|
131
|
+
return;
|
|
132
|
+
this.running = running;
|
|
133
|
+
this.refreshRuntime();
|
|
134
|
+
// Read once at activity transitions, not on clock ticks or text deltas.
|
|
135
|
+
if (this.sessionID)
|
|
136
|
+
this.changed.add(this.sessionID);
|
|
137
|
+
this.schedule(false);
|
|
138
|
+
}
|
|
105
139
|
schedule(full) {
|
|
106
140
|
if (this.disposed || !this.sessionID)
|
|
107
141
|
return;
|
|
@@ -126,12 +160,41 @@ export class UsageController {
|
|
|
126
160
|
clearTimeout(this.timer);
|
|
127
161
|
clearTimeout(this.retry);
|
|
128
162
|
clearTimeout(this.performanceTimer);
|
|
163
|
+
this.stopRuntime();
|
|
164
|
+
this.unsubscribeRuntime();
|
|
129
165
|
this.unsubscribePerformance();
|
|
130
166
|
this.unsubscribe();
|
|
131
167
|
if (this.ownsPerformance)
|
|
132
168
|
this.performance.dispose();
|
|
169
|
+
if (this.ownsRuntime)
|
|
170
|
+
this.runtime.dispose();
|
|
133
171
|
}
|
|
134
172
|
update(state) { this.state = state; this.publish(state); }
|
|
173
|
+
stopRuntime() { this.stopRuntimeTick?.(); this.stopRuntimeTick = undefined; }
|
|
174
|
+
runtimeSummary(now = this.runtime.now()) {
|
|
175
|
+
const prepared = this.preparedRuntime;
|
|
176
|
+
if (!prepared)
|
|
177
|
+
return undefined;
|
|
178
|
+
return this.running && this.runtimeFresh && this.sessionID && prepared.turn
|
|
179
|
+
&& this.runtime.canEstimate(this.sessionID, prepared.turn.messageID)
|
|
180
|
+
? projectRuntime(prepared, now) : prepared.summary;
|
|
181
|
+
}
|
|
182
|
+
refreshRuntime(now = this.runtime.now()) {
|
|
183
|
+
if (this.disposed || this.state.status !== "ready") {
|
|
184
|
+
this.stopRuntime();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const runtime = this.runtimeSummary(now);
|
|
188
|
+
if (runtime?.estimatedMilliseconds === undefined) {
|
|
189
|
+
// Freeze the last projection until a fresh snapshot can replace it with
|
|
190
|
+
// native completion metadata, rather than briefly showing the old subtotal.
|
|
191
|
+
this.stopRuntime();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.stopRuntimeTick ??= this.runtime.tick(time => this.refreshRuntime(time));
|
|
195
|
+
if (runtime.estimatedMilliseconds !== this.state.runtime?.estimatedMilliseconds)
|
|
196
|
+
this.update({ ...this.state, runtime });
|
|
197
|
+
}
|
|
135
198
|
refreshPerformance() {
|
|
136
199
|
if (this.disposed || !this.snapshot || this.performanceTimer)
|
|
137
200
|
return;
|
|
@@ -168,15 +231,18 @@ export class UsageController {
|
|
|
168
231
|
const history = viewedMessages(snapshot);
|
|
169
232
|
const session = snapshot.sessions.get(snapshot.viewedID);
|
|
170
233
|
const revertMessageID = session.revert?.messageID;
|
|
234
|
+
this.preparedRuntime = prepareRuntime(history, !!session.fork);
|
|
235
|
+
this.runtimeFresh = true;
|
|
171
236
|
this.update({
|
|
172
237
|
status: "ready",
|
|
173
238
|
summary: summarize(messages, snapshot.model.catalog),
|
|
174
239
|
context: contextUsage(history, snapshot.model.context, revertMessageID),
|
|
175
240
|
...(this.detailed ? { details: { request: requestDetails(history, revertMessageID), models: summarizeModels(messages, snapshot.model.catalog), sessionTitle: session.title } } : {}),
|
|
176
241
|
performance: this.performance.summaryPrepared(this.prepared, this.sessions),
|
|
177
|
-
runtime:
|
|
242
|
+
runtime: this.runtimeSummary(),
|
|
178
243
|
model: snapshot.model.label,
|
|
179
244
|
});
|
|
245
|
+
this.refreshRuntime();
|
|
180
246
|
// The server plugin may be absent. An optional RPC must not delay measured usage.
|
|
181
247
|
if (this.detailed && this.source.composition) {
|
|
182
248
|
void this.source.composition(session, AbortSignal.any([request.signal, AbortSignal.timeout(5_000)]))
|
|
@@ -192,6 +258,8 @@ export class UsageController {
|
|
|
192
258
|
if (this.disposed || generation !== this.generation)
|
|
193
259
|
return;
|
|
194
260
|
this.fullScan = true;
|
|
261
|
+
this.runtimeFresh = false;
|
|
262
|
+
this.stopRuntime();
|
|
195
263
|
this.update({
|
|
196
264
|
...this.state, status: this.state.summary ? "stale" : "unavailable",
|
|
197
265
|
runtime: this.state.runtime?.milliseconds === undefined
|
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ export default Plugin.define({
|
|
|
6
6
|
async setup(context) {
|
|
7
7
|
const capture = new ContextCapture(context.mcp, context.storage);
|
|
8
8
|
const rpc = await context.rpc.register(ContextSourceRpc, {
|
|
9
|
+
clock: async () => Date.now(),
|
|
9
10
|
latest: async (input) => {
|
|
10
11
|
const sessionID = input.sessionID;
|
|
11
12
|
return { estimate: await capture.latest(sessionID) ?? null };
|
package/dist/performance.d.ts
CHANGED
|
@@ -22,11 +22,14 @@ export interface StreamSample {
|
|
|
22
22
|
bytes: number;
|
|
23
23
|
}
|
|
24
24
|
export declare const LIVE_WINDOW_MS = 2000;
|
|
25
|
+
export declare const LIVE_MIN_SPAN_MS = 500;
|
|
26
|
+
export declare const LIVE_SAMPLE_MS = 100;
|
|
25
27
|
export declare const LIVE_EWMA_ALPHA = 0.35;
|
|
26
28
|
export declare const LIVE_BYTES_PER_TOKEN = 4;
|
|
27
|
-
/**
|
|
29
|
+
/** The first batch is a counter baseline, not bytes generated after its timestamp. */
|
|
28
30
|
export declare function estimatedLiveTps(samples: readonly StreamSample[]): number | undefined;
|
|
29
|
-
|
|
31
|
+
/** Time-based EWMA: elapsed time determines the weight, never delta count. */
|
|
32
|
+
export declare function smoothLiveTps(previous: number | undefined, raw: number, elapsed?: number): number;
|
|
30
33
|
export interface PreparedPerformance {
|
|
31
34
|
tps?: number;
|
|
32
35
|
ttftSum: number;
|
|
@@ -43,18 +46,20 @@ export declare function preparePerformance(messages: Iterable<UsageMessage>): Pr
|
|
|
43
46
|
export declare class PerformanceMonitor {
|
|
44
47
|
private readonly retention;
|
|
45
48
|
private readonly now;
|
|
49
|
+
private readonly monotonic;
|
|
46
50
|
private active;
|
|
47
51
|
private observed;
|
|
48
52
|
private listeners;
|
|
49
53
|
private cleanupTimer;
|
|
50
54
|
private disposed;
|
|
51
55
|
private readonly unsubscribe;
|
|
52
|
-
constructor(subscribe?: PerformanceSubscribe, retention?: number, now?: () => number);
|
|
56
|
+
constructor(subscribe?: PerformanceSubscribe, retention?: number, now?: () => number, monotonic?: () => number);
|
|
53
57
|
listen(listener: PerformanceListener): () => void;
|
|
54
58
|
dispose(): void;
|
|
55
59
|
private key;
|
|
56
60
|
private publish;
|
|
57
61
|
private deleteSession;
|
|
62
|
+
private resetLive;
|
|
58
63
|
private prune;
|
|
59
64
|
private scheduleCleanup;
|
|
60
65
|
handle(event: PerformanceEvent): boolean;
|