@chenlongapps/opencode-token-usage 0.4.7 → 0.4.8

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 CHANGED
@@ -34,9 +34,9 @@ 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. The current SDK 2.0.24 build has been verified on OpenCode 2.0.11, 2.0.22, and 2.0.24.
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 this addition.
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, 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. 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
 
@@ -44,7 +44,7 @@ For remote sessions, add the package name to `plugins` in your local `~/.config/
44
44
 
45
45
  Enter `/usage` in a session to open a native dialog with the session tree's token totals and estimated cost by recorded model. Scroll with ↑/↓, Page Up/Down, Home/End, press `d` to switch between compact and detailed numbers and reveal model rates, and close with Escape. The command does not send a prompt to the model.
46
46
 
47
- The subagent picker keeps the summary in the form `Token Usage · Context … · Total … · Cost … · TPS …`. 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 and TTFT) without reserving space for the full panel while closed.
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.
48
48
 
49
49
  The dialog is split into five sections with separate statistics:
50
50
 
@@ -53,7 +53,7 @@ The dialog is split into five sections with separate statistics:
53
53
  | Context Window | Used / limit and a percentage of the active model's context limit, with a usage bar |
54
54
  | Last Request | The five token categories and cache rate of the viewed session's most recent reported call |
55
55
  | Context Breakdown | Estimated prompt composition, largest first, with bars |
56
- | Session | Tree totals: steps, calls, tokens, cache rate, cost (single line, or per-category rows in detailed mode) |
56
+ | Session | Tree totals: steps, calls, tokens, cache rate, cost (single line, or per-category rows in detailed mode); a separate `Run Time (this session)` row sums only the viewed session's native per-turn response durations |
57
57
  | By Model | Tokens, calls and cost per recorded model, highest cost first; detailed mode shows the rates used for each model's cost estimate |
58
58
 
59
59
  One line under the title names the session and its active model, and the body never repeats the model name. Compact mode uses `K`/`M` abbreviations (`812`, `139.4K`, `3.70M`), hides empty rows and merges the two tool families in Context Breakdown into a single `Tools` row. Detailed mode shows exact numbers, empty rows, and the `System Tools` / `MCP Tools` split. The sidebar keeps exact numbers and its own layout.
@@ -76,6 +76,7 @@ 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` | Sum of the native response duration shown by OpenCode for each turn in the viewed session's retained history, taking only the final completed response per turn; never adds child-session durations |
79
80
  | `TPS` | Generation throughput for `Output + Reasoning` across the tree. While streaming, it estimates recent observable text, reasoning-summary and tool-input deltas (UTF-8 bytes / 4) over a short sliding window after the first delta, excluding pre-first-token waiting, and marks the value with `~`. APIs such as OpenAI Responses do not expose complete hidden reasoning tokens in real time, so live values cannot represent hidden reasoning throughput. After completion it uses provider-reported Output and Reasoning tokens and removes `~` |
80
81
  | `TTFT` | Average time to first token across measurable assistant steps in the tree |
81
82
 
@@ -85,9 +86,12 @@ Costs follow the sidebar's pricing and `partial`/unavailable/free conventions. I
85
86
  - A fork is a separate session tree. Inherited message copies are attributed only to their original source to prevent double counting.
86
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.
87
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
+ - `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` updates when completed responses are reported; unfinished responses are not estimated with a live clock. Failed/interrupted responses count when they have a native completion timestamp. 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 retain the last complete value and mark it `Not updated` (`stale` in the short summary).
91
+ - 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.
88
92
  - `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.
89
93
  - 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.
90
- - Confirmed free usage displays `$0.00`; 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).
94
+ - 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).
91
95
  - Live `TPS` with `~` is an estimate from recent observable deltas after the first delta over a ~2s sliding window with light smoothing; it excludes TTFT and hidden-reasoning waits. Without `~` it is exact provider-reported `Output + Reasoning` throughput. No model-specific multipliers or hidden-reasoning guesses are applied.
92
96
  - Initial read failures display `Unavailable`. Later failures retain the last complete snapshot, display `Not updated`, and retry automatically.
93
97
 
@@ -109,7 +113,7 @@ Failed validation never bumps the version. An unfinished tagged price release is
109
113
 
110
114
  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.
111
115
 
112
- `test:smoke` packages the real artifact and validates loading, refreshes, `/usage`, subagent aggregation, per-message pricing, first-party price fallback, model switching, TPS, and TTFT against an isolated OpenCode instance and a local mock provider. It requires Python 3, an available local port, and npm network access. It never modifies your existing OpenCode configuration or calls paid models.
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 a local mock provider. It requires Python 3, an available local port, and npm network access. It never modifies your existing OpenCode configuration or calls paid models.
113
117
 
114
118
  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.
115
119
 
package/README.zh-CN.md CHANGED
@@ -34,9 +34,9 @@ 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 上验证。
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` 时,面板会显示在原生侧边栏中。OpenCode 在子代理视图中隐藏侧边栏,因此插件会在输入区上方保留一行实时摘要,显示 Context、Total、Cost 和 TPS。点击摘要可在居中弹窗中查看完整统计;按 Escape 或点击 **esc** 即可关闭,关闭弹窗不会中断子代理。
39
+ 安装后重启 OpenCode。终端足够宽且 `session.sidebar` 设为 `auto` 时,面板会显示在原生侧边栏中。OpenCode 在子代理视图中隐藏侧边栏,因此插件会在输入区上方保留一行实时摘要,显示 Context、Total、Cost、Time 和 TPS。点击摘要可在居中弹窗中查看完整统计;按 Escape 或点击 **esc** 即可关闭,关闭弹窗不会中断子代理。
40
40
 
41
41
  连接远程服务器时,可将包名加入本机 `~/.config/opencode/cli.json` 的 `plugins`,仅加载终端入口;配置路径遵循 `XDG_CONFIG_HOME`。
42
42
 
@@ -44,7 +44,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
44
44
 
45
45
  在会话中输入 `/usage`,可打开原生弹窗查看当前会话树的 token 总量,以及按消息实际模型汇总的估算费用。使用 ↑/↓、Page Up/Down、Home/End 滚动,按 `d` 切换紧凑/详细数字并查看模型费率,Escape 关闭。该命令不会向模型发送消息。
46
46
 
47
- 子代理视图中的摘要格式为 `Token Usage · Context … · Total … · Cost … · TPS …`。缺失数据仍显示不可用,不会被当作零,摘要也不提示快捷键。点击后打开较小的弹窗,按侧边栏原有行序展示精确数值(包括 Steps、TPS 和 TTFT);关闭时不会为完整面板预留高度。
47
+ 子代理视图中的摘要格式为 `Token Usage · Context … · Total … · Cost … · Time … · TPS …`。Time 仅累计被查看子代理自身每轮原生显示的对话耗时,token 总量和费用仍统计整棵树。窄终端会省略优先级较低的字段,保留 Time 且不增加第二行。缺失数据仍显示不可用,不会被当作零,摘要也不提示快捷键。点击后打开较小的弹窗,按侧边栏原有行序展示精确数值(包括 Steps、TPS、TTFT 和 Run Time);关闭时不会为完整面板预留高度。Run Time 在侧边栏及该弹窗中均为最后一行。
48
48
 
49
49
  弹窗分为五个区域,统计口径互相独立:
50
50
 
@@ -53,7 +53,7 @@ opencode plugin add @chenlongapps/opencode-token-usage
53
53
  | Context Window | 已用 / 上限、占活动模型上下文上限的百分比,并带占用条 |
54
54
  | Last Request | 被查看会话最近一次已上报调用的五类 token 与缓存命中率 |
55
55
  | Context Breakdown | 提示词构成估算,按占比降序排列并带条形图 |
56
- | Session | 全树汇总:steps、calls、tokens、缓存率与费用(紧凑模式为单行,详细模式逐类展开) |
56
+ | Session | 全树汇总:steps、calls、tokens、缓存率与费用(紧凑模式为单行,详细模式逐类展开);独立的 `Run Time (this session)` 行仅累计被查看会话每轮原生显示的对话耗时 |
57
57
  | By Model | 各模型的 tokens、calls 与费用,按费用降序;详细模式显示用于估算各模型费用的费率 |
58
58
 
59
59
  标题下方一行注明会话与当前活动模型,正文不再重复模型名。紧凑模式使用 `K`/`M` 缩写(`812`、`139.4K`、`3.70M`),隐藏零值行,并把 Context Breakdown 中的两类工具合并为一行 `Tools`;详细模式显示精确数字、零值行及 `System Tools` / `MCP Tools` 拆分。侧边栏仍使用精确数字和原有布局。
@@ -76,6 +76,7 @@ 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` | 仅被查看会话自身的保留历史中,OpenCode 每轮原生显示的对话耗时之和,每轮只取最终已完成回答;不额外叠加子会话耗时 |
79
80
  | `TPS` | 全树 `Output + Reasoning` 的生成吞吐。请求进行中时,插件根据首个流式增量之后最近的可观察文本、reasoning summary 和工具输入流(UTF-8 字节数 / 4)按短时间滑动窗口估算输出速度,不包含首 token 前等待时间,并以 `~` 标记。OpenAI Responses 等 API 不会实时暴露完整的隐藏 reasoning token,因此实时值不能表示隐藏推理吞吐。请求完成后,插件使用服务端上报的 Output 与 Reasoning token 及时间信息计算精确 TPS,并移除 `~` 标记 |
80
81
  | `TTFT` | 全树中可测 assistant step 的平均首 token 时间 |
81
82
 
@@ -85,9 +86,12 @@ opencode plugin add @chenlongapps/opencode-token-usage
85
86
  - 分叉会话是独立会话树。继承消息副本只归原始来源,避免重复计费。
86
87
  - `Context` 仅搜索最后一次 `status === "completed"` 的 compaction 之后;没有可靠用量或模型上限时隐藏。
87
88
  - `Steps` 统计树中全部 assistant 消息,无论是否已上报用量;复用分叉副本去重,继承历史不会重复计数。
89
+ - `Run Time` 沿用 OpenCode 2.0.26 的原生回答页脚口径:assistant 完成时间减去上一条 idle 标记之后首个 user/synthetic 输入的创建时间。没有 idle 标记的旧历史使用最近一条输入;没有输入则回退到 assistant 自身的创建时间。每轮只计最终已完成回答的耗时,不把各条 assistant step 的重叠时长重复相加。包含跨度内的模型调用、工具、重试和前台等待,不含轮次之间的空闲;父会话等待子代理的时间已在父会话跨度内,不再额外叠加子代理耗时。
90
+ - `Run Time` 在已完成回答上报后更新,不用实时计时器估算未完成回答。失败/中断回答有原生完成时间戳时同样累计。先累加未取整的毫秒值,再格式化(不足一秒显示毫秒,不足一分钟的秒值保留一位小数,其后显示分/小时),因此不一定等于相加各轮已取整的页脚标签。复用现有消息历史读取,重启后可恢复,也适用于安装之前的会话;不依赖实验性执行日志 API 或服务端插件。历史计时数据缺失时显示 `—`,不会显示伪零。读取失败保留上次完整值并标注 `Not updated`(短摘要中为 `stale`)。
91
+ - 耗时随保留历史变化:切换 agent/模型与压缩保留过去轮次;分叉继承副本不计入分叉自身耗时。暂存回退不会删除轮次,但已提交回退删除消息后,也会移除这些消息对应的耗时;已删除的历史计时无法恢复。
88
92
  - `Est. Cost` 按每条消息记录的实际模型分别计算。OpenCode 当前解析出的完整非零价格优先;若 OpenCode 给出完整零价,且内置价格快照可以完整计价,则采用快照价格。价格不完整时整条消息回退,不混用两套费率。
89
93
  - 内置回退快照从 [models.dev](https://models.dev/api.json) 生成,只采用审核过的原厂 Provider 与自家模型系列;少量经原厂核实的例外单独维护。快照覆盖有价格的文本模型,插件运行时不下载价格。网关模型通过精确原厂 ID、明确别名和已知包装格式匹配;只有终尾 `-free` 或 `:free` 会在再次精确查找前移除。
90
- - 确认免费时显示 `$0.00`;价格不可用时显示 `—`;只有部分消息可计算时在已知小计后标注 `partial`。快照不含网关加价、区域溢价、未列明的折扣、非文本计费、工具费和税费;Est. Cost 是估算而非账单。覆盖范围、来源和限制见[价格来源与限制](docs/pricing.md)。
94
+ - 确认免费时显示 `$0.00`;正费用低于 `$0.001` 时显示 `<$0.001`,不超过 `$0.01` 的费用保留三位小数,更高费用保留两位。价格不可用时显示 `—`;只有部分消息可计算时在已知小计后标注 `partial`。快照不含网关加价、区域溢价、未列明的折扣、非文本计费、工具费和税费;Est. Cost 是估算而非账单。覆盖范围、来源和限制见[价格来源与限制](docs/pricing.md)。
91
95
  - 带 `~` 的实时 `TPS` 是首个流式增量之后约 2 秒滑动窗口内可观察增量的估算值(带轻度平滑),不包含 TTFT 与隐藏推理等待;不带 `~` 时为服务端上报的 `Output + Reasoning` 精确吞吐。不做模型专属倍率,也不猜测隐藏 reasoning token。
92
96
  - 首次读取失败显示 `Unavailable`;后续失败保留上次完整快照、标注 `Not updated` 并自动重试。
93
97
 
@@ -109,7 +113,7 @@ OpenCode SDK 包精确锁定为 `2.0.24`,OpenTUI 为 `0.5.14`。Node.js 22 下
109
113
 
110
114
  手动更新时,在联网的维护环境运行 `npm run prices:update`,审阅生成文件及例外差异,再执行上述检查。构建和插件刷新不会请求 models.dev。
111
115
 
112
- `test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage`、子代理累计、逐消息计价、原厂价格补全、模型切换、TPS 和 TTFT。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
116
+ `test:smoke` 会打包真实产物,并在隔离的 OpenCode 与本地模拟提供商中验证加载、刷新、`/usage`、子代理累计、逐消息计价、原厂价格补全、模型切换、TPS、TTFT,以及会话自身的原生对话耗时(未完成回答、空闲、中断、消息历史恢复、分叉和窄终端子代理视图)。它需要 Python 3、可用的本地端口和 npm 网络访问,不会修改现有 OpenCode 配置或调用付费模型。
113
117
 
114
118
  默认使用 `PATH` 中的 `opencode`。OpenCode 2.0.9 及之后的稳定版 2.x 可以运行;尚未与当前 SDK 验证的宿主会输出警告,然后继续实际兼容性检查。仅通过版本检查不代表兼容性已验证。SDK 依赖仍独立锁定,不随本机 CLI 更新而自动升级。
115
119
 
package/ROADMAP.md CHANGED
@@ -105,9 +105,17 @@ Steps 1,234
105
105
 
106
106
  * [x] 在 `/usage` 中显示模型的定价信息。
107
107
 
108
+ ## 开发中 — 会话累计对话耗时(未发布)
109
+
110
+ * [x] 按 OpenCode 2.0.26 原生回答页脚口径累计当前查看会话的全部保留轮次,每轮只取最终已完成回答
111
+ * [x] 不计轮次之间的空闲,不额外叠加子代理耗时,未完成回答不做实时估算
112
+ * [x] 复用完整消息历史,重开会话和插件重启后恢复累计值,不依赖执行日志 API
113
+ * [x] 侧边栏 `Run Time`、子代理单行 `Time`、`/usage` 的 `Run Time (this session)` 三处显示
114
+ * [x] 时间缺失显示不可用,读取失败保留旧值并标注未更新;分叉副本不继承耗时,已提交回退随删除历史扣除
115
+ * [x] 通过类型检查、160 项测试、构建及真实 OpenCode 2.0.26 打包集成验证
116
+
108
117
  ## 后续评估
109
118
 
110
119
  * [x] 首次 npm 发布:`@chenlongapps/opencode-token-usage@0.2.1`
111
120
 
112
121
  ## 待解决问题
113
-
@@ -1,6 +1,7 @@
1
1
  import type { UsageSource } from "./source.js";
2
2
  import { PerformanceMonitor } from "./performance.js";
3
3
  import type { PerformanceSummary } from "./performance.js";
4
+ import type { RuntimeSummary } from "./runtime.js";
4
5
  import type { ContextSources } from "./context-sources.js";
5
6
  import type { ContextUsage, ModelCost, RequestDetails, Summary } from "./usage.js";
6
7
  export interface UsageState {
@@ -14,6 +15,7 @@ export interface UsageState {
14
15
  sessionTitle?: string | undefined;
15
16
  };
16
17
  performance?: PerformanceSummary;
18
+ runtime?: RuntimeSummary;
17
19
  model?: string;
18
20
  }
19
21
  export interface UsageEvent {
@@ -1,5 +1,6 @@
1
1
  import { loadSnapshot, uniqueMessages, viewedMessages } from "./source.js";
2
2
  import { PerformanceMonitor, preparePerformance } from "./performance.js";
3
+ import { summarizeRuntime } from "./runtime.js";
3
4
  import { contextUsage, requestDetails, summarize, summarizeModels } from "./usage.js";
4
5
  const sessionEvents = new Set([
5
6
  "session.created", "session.deleted", "session.forked", "session.moved",
@@ -95,7 +96,7 @@ export class UsageController {
95
96
  this.changed.clear();
96
97
  this.fullScan = true;
97
98
  this.sessionID = sessionID;
98
- this.update({ status: "loading" });
99
+ this.update({ status: "loading", runtime: { status: "loading" } });
99
100
  void this.run();
100
101
  }
101
102
  refresh() {
@@ -173,6 +174,7 @@ export class UsageController {
173
174
  context: contextUsage(history, snapshot.model.context, revertMessageID),
174
175
  ...(this.detailed ? { details: { request: requestDetails(history, revertMessageID), models: summarizeModels(messages, snapshot.model.catalog), sessionTitle: session.title } } : {}),
175
176
  performance: this.performance.summaryPrepared(this.prepared, this.sessions),
177
+ runtime: summarizeRuntime(history, !!session.fork),
176
178
  model: snapshot.model.label,
177
179
  });
178
180
  // The server plugin may be absent. An optional RPC must not delay measured usage.
@@ -190,7 +192,11 @@ export class UsageController {
190
192
  if (this.disposed || generation !== this.generation)
191
193
  return;
192
194
  this.fullScan = true;
193
- this.update({ ...this.state, status: this.state.summary ? "stale" : "unavailable" });
195
+ this.update({
196
+ ...this.state, status: this.state.summary ? "stale" : "unavailable",
197
+ runtime: this.state.runtime?.milliseconds === undefined
198
+ ? { status: "unavailable" } : { ...this.state.runtime, status: "stale" },
199
+ });
194
200
  this.retry = setTimeout(() => this.schedule(false), this.retryDelay);
195
201
  }
196
202
  finally {
@@ -0,0 +1,15 @@
1
+ import type { UsageMessage } from "./usage.js";
2
+ export interface RuntimeSummary {
3
+ status: "loading" | "ready" | "stale" | "unavailable";
4
+ /** Sum of one native response duration per turn in the viewed session's retained history. */
5
+ milliseconds?: number | undefined;
6
+ }
7
+ /**
8
+ * OpenCode 2.0.26's TUI turnDuration/inputIndex: completed assistant time minus
9
+ * the first user/synthetic input after the previous idle marker, or the nearest
10
+ * input in legacy histories with no idle markers. With no input it falls back
11
+ * to the assistant's creation time. Take only the final response in each turn:
12
+ * intermediate footer durations overlap and must never be added separately.
13
+ * No execution-log API, client clock, child history, or additional reads.
14
+ */
15
+ export declare function summarizeRuntime(messages: readonly UsageMessage[], fork?: boolean): RuntimeSummary;
@@ -0,0 +1,72 @@
1
+ const timestamp = (value) => typeof value === "number" && value >= 0
2
+ && Number.isFinite(new Date(value).getTime());
3
+ /**
4
+ * OpenCode 2.0.26's TUI turnDuration/inputIndex: completed assistant time minus
5
+ * the first user/synthetic input after the previous idle marker, or the nearest
6
+ * input in legacy histories with no idle markers. With no input it falls back
7
+ * to the assistant's creation time. Take only the final response in each turn:
8
+ * intermediate footer durations overlap and must never be added separately.
9
+ * No execution-log API, client clock, child history, or additional reads.
10
+ */
11
+ export function summarizeRuntime(messages, fork = false) {
12
+ // Even a copied marker identifies the host's turn semantics for an open fork.
13
+ const legacy = !messages.some(message => message.type === "idle");
14
+ // Fork copies belong to the original session, including copied turn boundaries.
15
+ const history = fork ? messages.filter(message => !/_\d+$/.test(message.id)) : messages;
16
+ let milliseconds = 0, unavailable = false;
17
+ let input;
18
+ let response, completedResponse;
19
+ const finish = (closed) => {
20
+ if (!response)
21
+ return;
22
+ let selected = response;
23
+ if (selected.message.time?.completed === undefined) {
24
+ // A live tail has no displayed duration yet. Missing closed-turn metadata
25
+ // is different: don't disguise an unknown historical subtotal as zero.
26
+ const message = selected.message;
27
+ if (closed || !timestamp(message.time?.created) || !timestamp(selected.created) || message.finish || message.error || message.retry) {
28
+ unavailable = true;
29
+ return;
30
+ }
31
+ // A steer can start another response before idle. Keep the previously
32
+ // displayed footer until a newer completed response replaces it.
33
+ if (!completedResponse)
34
+ return;
35
+ selected = completedResponse;
36
+ }
37
+ const completed = selected.message.time?.completed;
38
+ const created = selected.created;
39
+ if (!timestamp(created) || !timestamp(completed)) {
40
+ unavailable = true;
41
+ return;
42
+ }
43
+ milliseconds += Math.max(0, completed - created);
44
+ };
45
+ for (const message of history) {
46
+ if (message.type === "idle") {
47
+ finish(true);
48
+ input = undefined;
49
+ response = completedResponse = undefined;
50
+ }
51
+ else if (message.type === "user" || message.type === "synthetic") {
52
+ if (legacy) {
53
+ finish(true);
54
+ input = undefined;
55
+ response = completedResponse = undefined;
56
+ }
57
+ input ??= message;
58
+ }
59
+ else if (message.type === "assistant") {
60
+ // Matches native footer eligibility. Older imported messages can lack
61
+ // finish metadata; their completed timestamp still permits a duration.
62
+ if (!message.error && !message.retry && (message.finish === "tool-calls" || message.finish === "unknown"))
63
+ continue;
64
+ response = { message, created: input ? input.time?.created : message.time?.created };
65
+ if (message.time?.completed !== undefined)
66
+ completedResponse = response;
67
+ }
68
+ }
69
+ finish(false);
70
+ return unavailable || !Number.isFinite(milliseconds)
71
+ ? { status: "unavailable" } : { status: "ready", milliseconds };
72
+ }
package/dist/tui.js CHANGED
@@ -14,10 +14,10 @@ import { UsageController } from "./controller.js";
14
14
  import { PerformanceMonitor } from "./performance.js";
15
15
  import { breakdownRows } from "./context-sources.js";
16
16
  import { createSource } from "./source.js";
17
- import { bar, countLabel, formatCompact, formatEstimatedCost, formatRateTier, formatTokens, rateRows, requestRows, summaryRows, usageRows } from "./usage.js";
17
+ import { bar, countLabel, fitUsageSummary, formatCompact, formatEstimatedCost, formatRateTier, formatRunTime, formatTokens, rateRows, requestRows, summaryRows, usageRows } from "./usage.js";
18
18
  function UsageView(props) {
19
- const rows = createMemo(() => usageRows(props.state().summary, props.state().context, props.state().performance));
20
- const performanceStart = createMemo(() => rows().findIndex(([label]) => label === "TPS" || label === "TTFT"));
19
+ const rows = createMemo(() => usageRows(props.state().summary, props.state().context, props.state().performance, props.state().runtime));
20
+ const performanceStart = createMemo(() => rows().findIndex(([label]) => label === "Run Time" || label === "TPS" || label === "TTFT"));
21
21
  const status = () => ({
22
22
  loading: "Loading…",
23
23
  ready: "",
@@ -207,6 +207,7 @@ function ChildUsageLauncher(props) {
207
207
  controller.dispose();
208
208
  unregister();
209
209
  });
210
+ const dimensions = useTerminalDimensions();
210
211
  const summary = createMemo(() => {
211
212
  const current = state();
212
213
  if (!current.summary) return `Token Usage · ${{
@@ -218,8 +219,7 @@ function ChildUsageLauncher(props) {
218
219
  const context = current.context ? `${formatTokens(current.context.used)} / ${formatTokens(current.context.limit)} (${current.context.percent.toFixed(1)}%)` : "—";
219
220
  const cost = formatEstimatedCost(current.summary.cost, current.summary.costStatus) ?? "—";
220
221
  const tps = current.performance?.tps !== undefined && Number.isFinite(current.performance.tps) ? `${current.performance.tpsEstimated ? "~" : ""}${Math.max(0, current.performance.tps).toFixed(1)} tok/s` : "—";
221
- const stale = current.status === "stale" ? " · Not updated" : "";
222
- return `Token Usage · Context ${context} · Total ${formatTokens(current.summary.total)} · Cost ${cost} · TPS ${tps}${stale}`;
222
+ return fitUsageSummary([["Context", context], ["Total", formatTokens(current.summary.total)], ["Cost", cost], ["Time", formatRunTime(current.runtime, true)], ["TPS", tps]], Math.max(20, dimensions().width - 8), current.status === "stale");
223
223
  });
224
224
  return (() => {
225
225
  var _el$17 = _$createElement("box"),
@@ -405,12 +405,12 @@ function UsageDialog(props) {
405
405
  _el$30 = _$createElement("text"),
406
406
  _el$31 = _$createElement("b"),
407
407
  _el$33 = _$createElement("text"),
408
- _el$63 = _$createElement("box"),
409
- _el$64 = _$createElement("text"),
410
- _el$66 = _$createElement("text"),
411
- _el$68 = _$createElement("text");
408
+ _el$67 = _$createElement("box"),
409
+ _el$68 = _$createElement("text"),
410
+ _el$70 = _$createElement("text"),
411
+ _el$72 = _$createElement("text");
412
412
  _$insertNode(_el$28, _el$29);
413
- _$insertNode(_el$28, _el$63);
413
+ _$insertNode(_el$28, _el$67);
414
414
  _$setProp(_el$28, "paddingLeft", 2);
415
415
  _$setProp(_el$28, "paddingRight", 2);
416
416
  _$setProp(_el$28, "paddingBottom", 1);
@@ -435,7 +435,7 @@ function UsageDialog(props) {
435
435
  _$effect(_$p => _$setProp(_el$35, "fg", theme().muted, _$p));
436
436
  return _el$35;
437
437
  }
438
- }), _el$63);
438
+ }), _el$67);
439
439
  _$insert(_el$28, _$createComponent(Show, {
440
440
  get when() {
441
441
  return state().status !== "ready";
@@ -446,7 +446,7 @@ function UsageDialog(props) {
446
446
  _$effect(_$p => _$setProp(_el$36, "fg", theme().muted, _$p));
447
447
  return _el$36;
448
448
  }
449
- }), _el$63);
449
+ }), _el$67);
450
450
  _$insert(_el$28, _$createComponent(Show, {
451
451
  get when() {
452
452
  return state().summary;
@@ -470,7 +470,10 @@ function UsageDialog(props) {
470
470
  _el$55 = _$createElement("b"),
471
471
  _el$57 = _$createElement("box"),
472
472
  _el$58 = _$createElement("text"),
473
- _el$59 = _$createElement("b");
473
+ _el$60 = _$createElement("text"),
474
+ _el$61 = _$createElement("box"),
475
+ _el$62 = _$createElement("text"),
476
+ _el$63 = _$createElement("b");
474
477
  _$insertNode(_el$37, _el$38);
475
478
  _$use(value => {
476
479
  scroll = value;
@@ -485,7 +488,7 @@ function UsageDialog(props) {
485
488
  _$insertNode(_el$38, _el$43);
486
489
  _$insertNode(_el$38, _el$48);
487
490
  _$insertNode(_el$38, _el$53);
488
- _$insertNode(_el$38, _el$57);
491
+ _$insertNode(_el$38, _el$61);
489
492
  _$setProp(_el$38, "gap", 1);
490
493
  _$insertNode(_el$39, _el$40);
491
494
  _$insertNode(_el$40, _el$41);
@@ -496,48 +499,48 @@ function UsageDialog(props) {
496
499
  },
497
500
  get fallback() {
498
501
  return (() => {
499
- var _el$70 = _$createElement("text");
500
- _$insertNode(_el$70, _$createTextNode(`Context usage unavailable`));
501
- _$effect(_$p => _$setProp(_el$70, "fg", theme().muted, _$p));
502
- return _el$70;
502
+ var _el$74 = _$createElement("text");
503
+ _$insertNode(_el$74, _$createTextNode(`Context usage unavailable`));
504
+ _$effect(_$p => _$setProp(_el$74, "fg", theme().muted, _$p));
505
+ return _el$74;
503
506
  })();
504
507
  },
505
508
  children: details => (() => {
506
- var _el$72 = _$createElement("box"),
507
- _el$73 = _$createElement("box"),
508
- _el$74 = _$createElement("text"),
509
- _el$76 = _$createElement("text"),
510
- _el$77 = _$createTextNode(` / `),
511
- _el$78 = _$createTextNode(` (`),
512
- _el$79 = _$createTextNode(`%)`);
513
- _$insertNode(_el$72, _el$73);
514
- _$insertNode(_el$73, _el$74);
515
- _$insertNode(_el$73, _el$76);
516
- _$setProp(_el$73, "flexDirection", "row");
517
- _$setProp(_el$73, "justifyContent", "space-between");
518
- _$setProp(_el$73, "gap", 2);
519
- _$insertNode(_el$74, _$createTextNode(`Used / Limit`));
520
- _$setProp(_el$74, "flexShrink", 0);
509
+ var _el$76 = _$createElement("box"),
510
+ _el$77 = _$createElement("box"),
511
+ _el$78 = _$createElement("text"),
512
+ _el$80 = _$createElement("text"),
513
+ _el$81 = _$createTextNode(` / `),
514
+ _el$82 = _$createTextNode(` (`),
515
+ _el$83 = _$createTextNode(`%)`);
521
516
  _$insertNode(_el$76, _el$77);
522
- _$insertNode(_el$76, _el$78);
523
- _$insertNode(_el$76, _el$79);
524
- _$setProp(_el$76, "wrapMode", "word");
525
- _$setProp(_el$76, "minWidth", 0);
526
- _$insert(_el$76, () => number(details().used), _el$77);
527
- _$insert(_el$76, () => number(details().limit), _el$78);
528
- _$insert(_el$76, () => details().percent.toFixed(1), _el$79);
529
- _$insert(_el$72, () => barRow(details().percent, windowBar()), null);
517
+ _$insertNode(_el$77, _el$78);
518
+ _$insertNode(_el$77, _el$80);
519
+ _$setProp(_el$77, "flexDirection", "row");
520
+ _$setProp(_el$77, "justifyContent", "space-between");
521
+ _$setProp(_el$77, "gap", 2);
522
+ _$insertNode(_el$78, _$createTextNode(`Used / Limit`));
523
+ _$setProp(_el$78, "flexShrink", 0);
524
+ _$insertNode(_el$80, _el$81);
525
+ _$insertNode(_el$80, _el$82);
526
+ _$insertNode(_el$80, _el$83);
527
+ _$setProp(_el$80, "wrapMode", "word");
528
+ _$setProp(_el$80, "minWidth", 0);
529
+ _$insert(_el$80, () => number(details().used), _el$81);
530
+ _$insert(_el$80, () => number(details().limit), _el$82);
531
+ _$insert(_el$80, () => details().percent.toFixed(1), _el$83);
532
+ _$insert(_el$76, () => barRow(details().percent, windowBar()), null);
530
533
  _$effect(_p$ => {
531
- var _v$21 = theme().muted,
532
- _v$22 = theme().base;
533
- _v$21 !== _p$.e && (_p$.e = _$setProp(_el$74, "fg", _v$21, _p$.e));
534
- _v$22 !== _p$.t && (_p$.t = _$setProp(_el$76, "fg", _v$22, _p$.t));
534
+ var _v$23 = theme().muted,
535
+ _v$24 = theme().base;
536
+ _v$23 !== _p$.e && (_p$.e = _$setProp(_el$78, "fg", _v$23, _p$.e));
537
+ _v$24 !== _p$.t && (_p$.t = _$setProp(_el$80, "fg", _v$24, _p$.t));
535
538
  return _p$;
536
539
  }, {
537
540
  e: undefined,
538
541
  t: undefined
539
542
  });
540
- return _el$72;
543
+ return _el$76;
541
544
  })()
542
545
  }), null);
543
546
  _$insertNode(_el$43, _el$44);
@@ -552,10 +555,10 @@ function UsageDialog(props) {
552
555
  return requestTime();
553
556
  },
554
557
  children: time => (() => {
555
- var _el$80 = _$createElement("text");
556
- _$insert(_el$80, time);
557
- _$effect(_$p => _$setProp(_el$80, "fg", theme().muted, _$p));
558
- return _el$80;
558
+ var _el$84 = _$createElement("text");
559
+ _$insert(_el$84, time);
560
+ _$effect(_$p => _$setProp(_el$84, "fg", theme().muted, _$p));
561
+ return _el$84;
559
562
  })()
560
563
  }), null);
561
564
  _$insert(_el$43, _$createComponent(Show, {
@@ -564,16 +567,16 @@ function UsageDialog(props) {
564
567
  },
565
568
  get fallback() {
566
569
  return (() => {
567
- var _el$81 = _$createElement("text");
568
- _$insertNode(_el$81, _$createTextNode(`Request usage unavailable`));
569
- _$effect(_$p => _$setProp(_el$81, "fg", theme().muted, _$p));
570
- return _el$81;
570
+ var _el$85 = _$createElement("text");
571
+ _$insertNode(_el$85, _$createTextNode(`Request usage unavailable`));
572
+ _$effect(_$p => _$setProp(_el$85, "fg", theme().muted, _$p));
573
+ return _el$85;
571
574
  })();
572
575
  },
573
576
  children: details => (() => {
574
- var _el$83 = _$createElement("box");
575
- _$insert(_el$83, () => requestRowsList(requestRows(details(), compact())));
576
- return _el$83;
577
+ var _el$87 = _$createElement("box");
578
+ _$insert(_el$87, () => requestRowsList(requestRows(details(), compact())));
579
+ return _el$87;
577
580
  })()
578
581
  }), null);
579
582
  _$insertNode(_el$48, _el$49);
@@ -588,10 +591,10 @@ function UsageDialog(props) {
588
591
  return sources();
589
592
  },
590
593
  children: captured => (() => {
591
- var _el$84 = _$createElement("text");
592
- _$insert(_el$84, () => new Date(captured().capturedAt).toLocaleTimeString());
593
- _$effect(_$p => _$setProp(_el$84, "fg", theme().muted, _$p));
594
- return _el$84;
594
+ var _el$88 = _$createElement("text");
595
+ _$insert(_el$88, () => new Date(captured().capturedAt).toLocaleTimeString());
596
+ _$effect(_$p => _$setProp(_el$88, "fg", theme().muted, _$p));
597
+ return _el$88;
595
598
  })()
596
599
  }), null);
597
600
  _$insert(_el$48, _$createComponent(Show, {
@@ -600,10 +603,10 @@ function UsageDialog(props) {
600
603
  },
601
604
  get fallback() {
602
605
  return (() => {
603
- var _el$85 = _$createElement("text");
604
- _$insertNode(_el$85, _$createTextNode(`Source estimates unavailable (no request snapshot or RPC)`));
605
- _$effect(_$p => _$setProp(_el$85, "fg", theme().muted, _$p));
606
- return _el$85;
606
+ var _el$89 = _$createElement("text");
607
+ _$insertNode(_el$89, _$createTextNode(`Source estimates unavailable (no request snapshot or RPC)`));
608
+ _$effect(_$p => _$setProp(_el$89, "fg", theme().muted, _$p));
609
+ return _el$89;
607
610
  })();
608
611
  },
609
612
  children: captured => _$createComponent(For, {
@@ -611,39 +614,39 @@ function UsageDialog(props) {
611
614
  return breakdownRows(captured(), detailed());
612
615
  },
613
616
  children: row => (() => {
614
- var _el$87 = _$createElement("box"),
615
- _el$88 = _$createElement("box"),
616
- _el$89 = _$createElement("text"),
617
- _el$90 = _$createElement("box"),
618
- _el$91 = _$createElement("text"),
619
- _el$92 = _$createElement("text"),
620
- _el$93 = _$createTextNode(`%`);
621
- _$insertNode(_el$87, _el$88);
622
- _$insertNode(_el$87, _el$90);
623
- _$insertNode(_el$87, _el$91);
624
- _$insertNode(_el$87, _el$92);
625
- _$setProp(_el$87, "flexDirection", "row");
626
- _$setProp(_el$87, "gap", 1);
627
- _$insertNode(_el$88, _el$89);
628
- _$setProp(_el$88, "width", 14);
629
- _$setProp(_el$88, "flexShrink", 0);
630
- _$insert(_el$89, () => row.label);
631
- _$setProp(_el$90, "flexShrink", 0);
632
- _$insert(_el$90, () => barRow(row.percent, breakdownBar()));
633
- _$setProp(_el$91, "flexShrink", 0);
634
- _$insert(_el$91, () => number(row.tokens));
617
+ var _el$91 = _$createElement("box"),
618
+ _el$92 = _$createElement("box"),
619
+ _el$93 = _$createElement("text"),
620
+ _el$94 = _$createElement("box"),
621
+ _el$95 = _$createElement("text"),
622
+ _el$96 = _$createElement("text"),
623
+ _el$97 = _$createTextNode(`%`);
624
+ _$insertNode(_el$91, _el$92);
625
+ _$insertNode(_el$91, _el$94);
626
+ _$insertNode(_el$91, _el$95);
627
+ _$insertNode(_el$91, _el$96);
628
+ _$setProp(_el$91, "flexDirection", "row");
629
+ _$setProp(_el$91, "gap", 1);
635
630
  _$insertNode(_el$92, _el$93);
631
+ _$setProp(_el$92, "width", 14);
636
632
  _$setProp(_el$92, "flexShrink", 0);
637
- _$insert(_el$92, () => row.percent.toFixed(1), _el$93);
633
+ _$insert(_el$93, () => row.label);
634
+ _$setProp(_el$94, "flexShrink", 0);
635
+ _$insert(_el$94, () => barRow(row.percent, breakdownBar()));
636
+ _$setProp(_el$95, "flexShrink", 0);
637
+ _$insert(_el$95, () => number(row.tokens));
638
+ _$insertNode(_el$96, _el$97);
639
+ _$setProp(_el$96, "flexShrink", 0);
640
+ _$insert(_el$96, () => row.percent.toFixed(1), _el$97);
638
641
  _$effect(_p$ => {
639
- var _v$23 = theme().muted,
640
- _v$24 = breakdownBar(),
641
- _v$25 = theme().base,
642
- _v$26 = theme().muted;
643
- _v$23 !== _p$.e && (_p$.e = _$setProp(_el$89, "fg", _v$23, _p$.e));
644
- _v$24 !== _p$.t && (_p$.t = _$setProp(_el$90, "width", _v$24, _p$.t));
645
- _v$25 !== _p$.a && (_p$.a = _$setProp(_el$91, "fg", _v$25, _p$.a));
646
- _v$26 !== _p$.o && (_p$.o = _$setProp(_el$92, "fg", _v$26, _p$.o));
642
+ var _v$25 = theme().muted,
643
+ _v$26 = breakdownBar(),
644
+ _v$27 = theme().base,
645
+ _v$28 = theme().muted;
646
+ _v$25 !== _p$.e && (_p$.e = _$setProp(_el$93, "fg", _v$25, _p$.e));
647
+ _v$26 !== _p$.t && (_p$.t = _$setProp(_el$94, "width", _v$26, _p$.t));
648
+ _v$27 !== _p$.a && (_p$.a = _$setProp(_el$95, "fg", _v$27, _p$.a));
649
+ _v$28 !== _p$.o && (_p$.o = _$setProp(_el$96, "fg", _v$28, _p$.o));
647
650
  return _p$;
648
651
  }, {
649
652
  e: undefined,
@@ -651,11 +654,12 @@ function UsageDialog(props) {
651
654
  a: undefined,
652
655
  o: undefined
653
656
  });
654
- return _el$87;
657
+ return _el$91;
655
658
  })()
656
659
  })
657
660
  }), null);
658
661
  _$insertNode(_el$53, _el$54);
662
+ _$insertNode(_el$53, _el$57);
659
663
  _$insertNode(_el$54, _el$55);
660
664
  _$insertNode(_el$55, _$createTextNode(`Session`));
661
665
  _$insert(_el$53, _$createComponent(Show, {
@@ -664,10 +668,10 @@ function UsageDialog(props) {
664
668
  },
665
669
  get fallback() {
666
670
  return (() => {
667
- var _el$94 = _$createElement("text");
668
- _$insertNode(_el$94, _$createTextNode(`Session usage unavailable`));
669
- _$effect(_$p => _$setProp(_el$94, "fg", theme().muted, _$p));
670
- return _el$94;
671
+ var _el$98 = _$createElement("text");
672
+ _$insertNode(_el$98, _$createTextNode(`Session usage unavailable`));
673
+ _$effect(_$p => _$setProp(_el$98, "fg", theme().muted, _$p));
674
+ return _el$98;
671
675
  })();
672
676
  },
673
677
  children: summary => _$createComponent(Show, {
@@ -676,26 +680,26 @@ function UsageDialog(props) {
676
680
  },
677
681
  get fallback() {
678
682
  return (() => {
679
- var _el$96 = _$createElement("text"),
680
- _el$97 = _$createTextNode(` · `),
681
- _el$98 = _$createTextNode(` · `),
682
- _el$99 = _$createTextNode(` tokens · `),
683
- _el$100 = _$createTextNode(`% cached`);
684
- _$insertNode(_el$96, _el$97);
685
- _$insertNode(_el$96, _el$98);
686
- _$insertNode(_el$96, _el$99);
687
- _$insertNode(_el$96, _el$100);
688
- _$setProp(_el$96, "wrapMode", "word");
689
- _$insert(_el$96, () => countLabel(summary().steps, "step"), _el$97);
690
- _$insert(_el$96, () => countLabel(summary().calls, "call"), _el$98);
691
- _$insert(_el$96, () => formatCompact(summary().total), _el$99);
692
- _$insert(_el$96, () => (summary().cacheRate * 100).toFixed(1), _el$100);
693
- _$insert(_el$96, (() => {
683
+ var _el$100 = _$createElement("text"),
684
+ _el$101 = _$createTextNode(` · `),
685
+ _el$102 = _$createTextNode(` · `),
686
+ _el$103 = _$createTextNode(` tokens · `),
687
+ _el$104 = _$createTextNode(`% cached`);
688
+ _$insertNode(_el$100, _el$101);
689
+ _$insertNode(_el$100, _el$102);
690
+ _$insertNode(_el$100, _el$103);
691
+ _$insertNode(_el$100, _el$104);
692
+ _$setProp(_el$100, "wrapMode", "word");
693
+ _$insert(_el$100, () => countLabel(summary().steps, "step"), _el$101);
694
+ _$insert(_el$100, () => countLabel(summary().calls, "call"), _el$102);
695
+ _$insert(_el$100, () => formatCompact(summary().total), _el$103);
696
+ _$insert(_el$100, () => (summary().cacheRate * 100).toFixed(1), _el$104);
697
+ _$insert(_el$100, (() => {
694
698
  var _c$ = _$memo(() => !!formatEstimatedCost(summary().cost, summary().costStatus));
695
699
  return () => _c$() ? ` · ${formatEstimatedCost(summary().cost, summary().costStatus)}` : "";
696
700
  })(), null);
697
- _$effect(_$p => _$setProp(_el$96, "fg", theme().base, _$p));
698
- return _el$96;
701
+ _$effect(_$p => _$setProp(_el$100, "fg", theme().base, _$p));
702
+ return _el$100;
699
703
  })();
700
704
  },
701
705
  get children() {
@@ -704,58 +708,69 @@ function UsageDialog(props) {
704
708
  return summaryRows(summary());
705
709
  },
706
710
  children: ([label, value]) => (() => {
707
- var _el$101 = _$createElement("box"),
708
- _el$102 = _$createElement("text"),
709
- _el$103 = _$createElement("text");
710
- _$insertNode(_el$101, _el$102);
711
- _$insertNode(_el$101, _el$103);
712
- _$setProp(_el$101, "flexDirection", "row");
713
- _$setProp(_el$101, "justifyContent", "space-between");
714
- _$setProp(_el$101, "gap", 2);
715
- _$setProp(_el$102, "flexShrink", 0);
716
- _$insert(_el$102, label);
717
- _$insert(_el$103, value);
711
+ var _el$105 = _$createElement("box"),
712
+ _el$106 = _$createElement("text"),
713
+ _el$107 = _$createElement("text");
714
+ _$insertNode(_el$105, _el$106);
715
+ _$insertNode(_el$105, _el$107);
716
+ _$setProp(_el$105, "flexDirection", "row");
717
+ _$setProp(_el$105, "justifyContent", "space-between");
718
+ _$setProp(_el$105, "gap", 2);
719
+ _$setProp(_el$106, "flexShrink", 0);
720
+ _$insert(_el$106, label);
721
+ _$insert(_el$107, value);
718
722
  _$effect(_p$ => {
719
- var _v$27 = theme().muted,
720
- _v$28 = theme().base;
721
- _v$27 !== _p$.e && (_p$.e = _$setProp(_el$102, "fg", _v$27, _p$.e));
722
- _v$28 !== _p$.t && (_p$.t = _$setProp(_el$103, "fg", _v$28, _p$.t));
723
+ var _v$29 = theme().muted,
724
+ _v$30 = theme().base;
725
+ _v$29 !== _p$.e && (_p$.e = _$setProp(_el$106, "fg", _v$29, _p$.e));
726
+ _v$30 !== _p$.t && (_p$.t = _$setProp(_el$107, "fg", _v$30, _p$.t));
723
727
  return _p$;
724
728
  }, {
725
729
  e: undefined,
726
730
  t: undefined
727
731
  });
728
- return _el$101;
732
+ return _el$105;
729
733
  })()
730
734
  });
731
735
  }
732
736
  })
733
- }), null);
737
+ }), _el$57);
734
738
  _$insertNode(_el$57, _el$58);
735
- _$insertNode(_el$58, _el$59);
736
- _$insertNode(_el$59, _$createTextNode(`By Model`));
737
- _$insert(_el$57, _$createComponent(Show, {
739
+ _$insertNode(_el$57, _el$60);
740
+ _$setProp(_el$57, "flexDirection", "row");
741
+ _$setProp(_el$57, "justifyContent", "space-between");
742
+ _$setProp(_el$57, "gap", 2);
743
+ _$insertNode(_el$58, _$createTextNode(`Run Time (this session)`));
744
+ _$setProp(_el$58, "wrapMode", "word");
745
+ _$setProp(_el$58, "minWidth", 0);
746
+ _$setProp(_el$60, "wrapMode", "word");
747
+ _$setProp(_el$60, "minWidth", 0);
748
+ _$insert(_el$60, () => formatRunTime(state().runtime));
749
+ _$insertNode(_el$61, _el$62);
750
+ _$insertNode(_el$62, _el$63);
751
+ _$insertNode(_el$63, _$createTextNode(`By Model`));
752
+ _$insert(_el$61, _$createComponent(Show, {
738
753
  get when() {
739
754
  return _$memo(() => !!detailed())() && models().length;
740
755
  },
741
756
  get children() {
742
- var _el$61 = _$createElement("text");
743
- _$insertNode(_el$61, _$createTextNode(`Rates used for Est. Cost · USD / 1M text tokens · incoming = Input + Cache Read + Cache Write`));
744
- _$setProp(_el$61, "wrapMode", "word");
745
- _$effect(_$p => _$setProp(_el$61, "fg", theme().muted, _$p));
746
- return _el$61;
757
+ var _el$65 = _$createElement("text");
758
+ _$insertNode(_el$65, _$createTextNode(`Rates used for Est. Cost · USD / 1M text tokens · incoming = Input + Cache Read + Cache Write`));
759
+ _$setProp(_el$65, "wrapMode", "word");
760
+ _$effect(_$p => _$setProp(_el$65, "fg", theme().muted, _$p));
761
+ return _el$65;
747
762
  }
748
763
  }), null);
749
- _$insert(_el$57, _$createComponent(Show, {
764
+ _$insert(_el$61, _$createComponent(Show, {
750
765
  get when() {
751
766
  return models().length;
752
767
  },
753
768
  get fallback() {
754
769
  return (() => {
755
- var _el$104 = _$createElement("text");
756
- _$insertNode(_el$104, _$createTextNode(`No model usage yet`));
757
- _$effect(_$p => _$setProp(_el$104, "fg", theme().muted, _$p));
758
- return _el$104;
770
+ var _el$108 = _$createElement("text");
771
+ _$insertNode(_el$108, _$createTextNode(`No model usage yet`));
772
+ _$effect(_$p => _$setProp(_el$108, "fg", theme().muted, _$p));
773
+ return _el$108;
759
774
  })();
760
775
  },
761
776
  get children() {
@@ -764,29 +779,29 @@ function UsageDialog(props) {
764
779
  return models();
765
780
  },
766
781
  children: model => (() => {
767
- var _el$106 = _$createElement("box"),
768
- _el$107 = _$createElement("box"),
769
- _el$108 = _$createElement("text"),
770
- _el$109 = _$createElement("text"),
771
- _el$110 = _$createElement("text"),
772
- _el$111 = _$createTextNode(` tokens · `);
773
- _$insertNode(_el$106, _el$107);
774
- _$insertNode(_el$106, _el$110);
775
- _$insertNode(_el$107, _el$108);
776
- _$insertNode(_el$107, _el$109);
777
- _$setProp(_el$107, "flexDirection", "row");
778
- _$setProp(_el$107, "justifyContent", "space-between");
779
- _$setProp(_el$107, "gap", 2);
780
- _$setProp(_el$108, "wrapMode", "word");
781
- _$setProp(_el$108, "minWidth", 0);
782
- _$setProp(_el$108, "flexGrow", 1);
783
- _$insert(_el$108, () => model.model);
784
- _$setProp(_el$109, "flexShrink", 0);
785
- _$insert(_el$109, () => formatEstimatedCost(model.cost, model.costStatus) ?? "");
782
+ var _el$110 = _$createElement("box"),
783
+ _el$111 = _$createElement("box"),
784
+ _el$112 = _$createElement("text"),
785
+ _el$113 = _$createElement("text"),
786
+ _el$114 = _$createElement("text"),
787
+ _el$115 = _$createTextNode(` tokens · `);
786
788
  _$insertNode(_el$110, _el$111);
787
- _$insert(_el$110, () => number(model.tokens), _el$111);
788
- _$insert(_el$110, () => countLabel(model.calls, "call"), null);
789
- _$insert(_el$106, _$createComponent(Show, {
789
+ _$insertNode(_el$110, _el$114);
790
+ _$insertNode(_el$111, _el$112);
791
+ _$insertNode(_el$111, _el$113);
792
+ _$setProp(_el$111, "flexDirection", "row");
793
+ _$setProp(_el$111, "justifyContent", "space-between");
794
+ _$setProp(_el$111, "gap", 2);
795
+ _$setProp(_el$112, "wrapMode", "word");
796
+ _$setProp(_el$112, "minWidth", 0);
797
+ _$setProp(_el$112, "flexGrow", 1);
798
+ _$insert(_el$112, () => model.model);
799
+ _$setProp(_el$113, "flexShrink", 0);
800
+ _$insert(_el$113, () => formatEstimatedCost(model.cost, model.costStatus) ?? "");
801
+ _$insertNode(_el$114, _el$115);
802
+ _$insert(_el$114, () => number(model.tokens), _el$115);
803
+ _$insert(_el$114, () => countLabel(model.calls, "call"), null);
804
+ _$insert(_el$110, _$createComponent(Show, {
790
805
  get when() {
791
806
  return detailed();
792
807
  },
@@ -796,52 +811,52 @@ function UsageDialog(props) {
796
811
  return model.appliedRates;
797
812
  },
798
813
  children: rates => (() => {
799
- var _el$114 = _$createElement("box"),
800
- _el$115 = _$createElement("text"),
801
- _el$116 = _$createTextNode(` · `),
802
- _el$117 = _$createTextNode(` · `);
803
- _$insertNode(_el$114, _el$115);
804
- _$setProp(_el$114, "marginTop", 1);
805
- _$insertNode(_el$115, _el$116);
806
- _$insertNode(_el$115, _el$117);
807
- _$setProp(_el$115, "wrapMode", "word");
808
- _$insert(_el$115, () => rates.source, _el$116);
809
- _$insert(_el$115, () => formatRateTier(rates), _el$117);
810
- _$insert(_el$115, () => countLabel(rates.calls, "call"), null);
811
- _$insert(_el$114, () => requestRowsList(rateRows(rates)), null);
812
- _$effect(_$p => _$setProp(_el$115, "fg", theme().muted, _$p));
813
- return _el$114;
814
+ var _el$118 = _$createElement("box"),
815
+ _el$119 = _$createElement("text"),
816
+ _el$120 = _$createTextNode(` · `),
817
+ _el$121 = _$createTextNode(` · `);
818
+ _$insertNode(_el$118, _el$119);
819
+ _$setProp(_el$118, "marginTop", 1);
820
+ _$insertNode(_el$119, _el$120);
821
+ _$insertNode(_el$119, _el$121);
822
+ _$setProp(_el$119, "wrapMode", "word");
823
+ _$insert(_el$119, () => rates.source, _el$120);
824
+ _$insert(_el$119, () => formatRateTier(rates), _el$121);
825
+ _$insert(_el$119, () => countLabel(rates.calls, "call"), null);
826
+ _$insert(_el$118, () => requestRowsList(rateRows(rates)), null);
827
+ _$effect(_$p => _$setProp(_el$119, "fg", theme().muted, _$p));
828
+ return _el$118;
814
829
  })()
815
830
  }), _$createComponent(Show, {
816
831
  get when() {
817
832
  return model.unpricedCalls > 0;
818
833
  },
819
834
  get children() {
820
- var _el$112 = _$createElement("text"),
821
- _el$113 = _$createTextNode(`Pricing unavailable for `);
822
- _$insertNode(_el$112, _el$113);
823
- _$setProp(_el$112, "wrapMode", "word");
824
- _$insert(_el$112, () => countLabel(model.unpricedCalls, "call"), null);
825
- _$effect(_$p => _$setProp(_el$112, "fg", theme().muted, _$p));
826
- return _el$112;
835
+ var _el$116 = _$createElement("text"),
836
+ _el$117 = _$createTextNode(`Pricing unavailable for `);
837
+ _$insertNode(_el$116, _el$117);
838
+ _$setProp(_el$116, "wrapMode", "word");
839
+ _$insert(_el$116, () => countLabel(model.unpricedCalls, "call"), null);
840
+ _$effect(_$p => _$setProp(_el$116, "fg", theme().muted, _$p));
841
+ return _el$116;
827
842
  }
828
843
  })];
829
844
  }
830
845
  }), null);
831
846
  _$effect(_p$ => {
832
- var _v$29 = theme().base,
833
- _v$30 = theme().base,
834
- _v$31 = theme().muted;
835
- _v$29 !== _p$.e && (_p$.e = _$setProp(_el$108, "fg", _v$29, _p$.e));
836
- _v$30 !== _p$.t && (_p$.t = _$setProp(_el$109, "fg", _v$30, _p$.t));
837
- _v$31 !== _p$.a && (_p$.a = _$setProp(_el$110, "fg", _v$31, _p$.a));
847
+ var _v$31 = theme().base,
848
+ _v$32 = theme().base,
849
+ _v$33 = theme().muted;
850
+ _v$31 !== _p$.e && (_p$.e = _$setProp(_el$112, "fg", _v$31, _p$.e));
851
+ _v$32 !== _p$.t && (_p$.t = _$setProp(_el$113, "fg", _v$32, _p$.t));
852
+ _v$33 !== _p$.a && (_p$.a = _$setProp(_el$114, "fg", _v$33, _p$.a));
838
853
  return _p$;
839
854
  }, {
840
855
  e: undefined,
841
856
  t: undefined,
842
857
  a: undefined
843
858
  });
844
- return _el$106;
859
+ return _el$110;
845
860
  })()
846
861
  });
847
862
  }
@@ -852,13 +867,17 @@ function UsageDialog(props) {
852
867
  _v$12 = theme().base,
853
868
  _v$13 = theme().base,
854
869
  _v$14 = theme().base,
855
- _v$15 = theme().base;
870
+ _v$15 = theme().muted,
871
+ _v$16 = theme().base,
872
+ _v$17 = theme().base;
856
873
  _v$10 !== _p$.e && (_p$.e = _$setProp(_el$37, "maxHeight", _v$10, _p$.e));
857
874
  _v$11 !== _p$.t && (_p$.t = _$setProp(_el$40, "fg", _v$11, _p$.t));
858
875
  _v$12 !== _p$.a && (_p$.a = _$setProp(_el$45, "fg", _v$12, _p$.a));
859
876
  _v$13 !== _p$.o && (_p$.o = _$setProp(_el$50, "fg", _v$13, _p$.o));
860
877
  _v$14 !== _p$.i && (_p$.i = _$setProp(_el$54, "fg", _v$14, _p$.i));
861
878
  _v$15 !== _p$.n && (_p$.n = _$setProp(_el$58, "fg", _v$15, _p$.n));
879
+ _v$16 !== _p$.s && (_p$.s = _$setProp(_el$60, "fg", _v$16, _p$.s));
880
+ _v$17 !== _p$.h && (_p$.h = _$setProp(_el$62, "fg", _v$17, _p$.h));
862
881
  return _p$;
863
882
  }, {
864
883
  e: undefined,
@@ -866,32 +885,34 @@ function UsageDialog(props) {
866
885
  a: undefined,
867
886
  o: undefined,
868
887
  i: undefined,
869
- n: undefined
888
+ n: undefined,
889
+ s: undefined,
890
+ h: undefined
870
891
  });
871
892
  return _el$37;
872
893
  }
873
- }), _el$63);
874
- _$insertNode(_el$63, _el$64);
875
- _$insertNode(_el$63, _el$66);
876
- _$insertNode(_el$63, _el$68);
877
- _$setProp(_el$63, "flexDirection", "row");
878
- _$insertNode(_el$64, _$createTextNode(`↑/↓ scroll · `));
879
- _$insertNode(_el$66, _$createTextNode(`d details`));
880
- _$setProp(_el$66, "onMouseUp", event => {
894
+ }), _el$67);
895
+ _$insertNode(_el$67, _el$68);
896
+ _$insertNode(_el$67, _el$70);
897
+ _$insertNode(_el$67, _el$72);
898
+ _$setProp(_el$67, "flexDirection", "row");
899
+ _$insertNode(_el$68, _$createTextNode(`↑/↓ scroll · `));
900
+ _$insertNode(_el$70, _$createTextNode(`d details`));
901
+ _$setProp(_el$70, "onMouseUp", event => {
881
902
  if (event.button === 0) toggleDetails();
882
903
  });
883
- _$insertNode(_el$68, _$createTextNode(` · esc close`));
904
+ _$insertNode(_el$72, _$createTextNode(` · esc close`));
884
905
  _$effect(_p$ => {
885
- var _v$16 = theme().base,
886
- _v$17 = theme().muted,
887
- _v$18 = theme().muted,
906
+ var _v$18 = theme().base,
888
907
  _v$19 = theme().muted,
889
- _v$20 = theme().muted;
890
- _v$16 !== _p$.e && (_p$.e = _$setProp(_el$30, "fg", _v$16, _p$.e));
891
- _v$17 !== _p$.t && (_p$.t = _$setProp(_el$33, "fg", _v$17, _p$.t));
892
- _v$18 !== _p$.a && (_p$.a = _$setProp(_el$64, "fg", _v$18, _p$.a));
893
- _v$19 !== _p$.o && (_p$.o = _$setProp(_el$66, "fg", _v$19, _p$.o));
894
- _v$20 !== _p$.i && (_p$.i = _$setProp(_el$68, "fg", _v$20, _p$.i));
908
+ _v$20 = theme().muted,
909
+ _v$21 = theme().muted,
910
+ _v$22 = theme().muted;
911
+ _v$18 !== _p$.e && (_p$.e = _$setProp(_el$30, "fg", _v$18, _p$.e));
912
+ _v$19 !== _p$.t && (_p$.t = _$setProp(_el$33, "fg", _v$19, _p$.t));
913
+ _v$20 !== _p$.a && (_p$.a = _$setProp(_el$68, "fg", _v$20, _p$.a));
914
+ _v$21 !== _p$.o && (_p$.o = _$setProp(_el$70, "fg", _v$21, _p$.o));
915
+ _v$22 !== _p$.i && (_p$.i = _$setProp(_el$72, "fg", _v$22, _p$.i));
895
916
  return _p$;
896
917
  }, {
897
918
  e: undefined,
package/dist/usage.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { PerformanceSummary } from "./performance.js";
2
+ import type { RuntimeSummary } from "./runtime.js";
2
3
  export interface Tokens {
3
4
  input: number;
4
5
  output: number;
@@ -42,6 +43,9 @@ export interface UsageMessage {
42
43
  id: string;
43
44
  type: string;
44
45
  status?: string;
46
+ finish?: string;
47
+ error?: unknown;
48
+ retry?: unknown;
45
49
  model?: ModelRef;
46
50
  cost?: number;
47
51
  tokens?: TokenInput;
@@ -137,6 +141,11 @@ export interface RequestDetails {
137
141
  export declare function requestDetails(messages: readonly UsageMessage[], revertMessageID?: string): RequestDetails | undefined;
138
142
  export declare function contextUsage(messages: readonly UsageMessage[], limit?: number, revertMessageID?: string): ContextUsage | undefined;
139
143
  export declare function formatTokens(value: number): string;
144
+ /** Native sub-minute precision; longer durations keep compact, readable units. */
145
+ export declare function formatDuration(milliseconds: number, compact?: boolean): string;
146
+ export declare function formatRunTime(runtime?: RuntimeSummary, compact?: boolean): string;
147
+ /** Preserve session-local time on narrow terminals without adding composer height. */
148
+ export declare function fitUsageSummary(fields: readonly (readonly [string, string])[], width: number, stale?: boolean): string;
140
149
  export declare const formatCost: (value: number) => string;
141
150
  export declare function formatEstimatedCost(cost: number, status: CostStatus): string | undefined;
142
151
  /** Per-million rates need more precision than a rounded total cost. */
@@ -159,4 +168,4 @@ export declare function requestRows(details: RequestDetails, compact: boolean):
159
168
  export declare function summaryRows(summary: Summary): readonly (readonly [string, string])[];
160
169
  /** `1 step` reads better than `1 steps` in the dialog's single-line summaries. */
161
170
  export declare const countLabel: (value: number, noun: string) => string;
162
- export declare function usageRows(summary?: Summary, context?: ContextUsage, performance?: PerformanceSummary): readonly (readonly [string, string])[];
171
+ export declare function usageRows(summary?: Summary, context?: ContextUsage, performance?: PerformanceSummary, runtime?: RuntimeSummary): readonly (readonly [string, string])[];
package/dist/usage.js CHANGED
@@ -191,7 +191,55 @@ export function contextUsage(messages, limit, revertMessageID) {
191
191
  export function formatTokens(value) {
192
192
  return Math.round(safe(value)).toLocaleString("en-US");
193
193
  }
194
- export const formatCost = (value) => value > 0 && value < 0.01 ? "<$0.01" : `$${safe(value).toFixed(2)}`;
194
+ /** Native sub-minute precision; longer durations keep compact, readable units. */
195
+ export function formatDuration(milliseconds, compact = false) {
196
+ if (!Number.isFinite(milliseconds) || milliseconds < 0)
197
+ return "—";
198
+ if (milliseconds === 0)
199
+ return "0s";
200
+ if (milliseconds < 1_000)
201
+ return `${milliseconds}ms`;
202
+ if (milliseconds < 60_000)
203
+ return `${(milliseconds / 1_000).toFixed(1)}s`;
204
+ const seconds = Math.floor(milliseconds / 1_000);
205
+ const hours = Math.floor(seconds / 3_600), minutes = Math.floor(seconds / 60) % 60, rest = seconds % 60;
206
+ if (hours > 0)
207
+ return compact ? `${hours}h${minutes}m${rest}s` : `${hours}h ${String(minutes).padStart(2, "0")}m ${String(rest).padStart(2, "0")}s`;
208
+ if (minutes > 0)
209
+ return compact ? `${minutes}m${rest}s` : `${minutes}m ${String(rest).padStart(2, "0")}s`;
210
+ return `${rest}s`;
211
+ }
212
+ export function formatRunTime(runtime, compact = false) {
213
+ if (runtime?.milliseconds === undefined)
214
+ return "—";
215
+ const time = formatDuration(runtime.milliseconds, compact);
216
+ return runtime.status === "stale" ? `${time} · ${compact ? "stale" : "Not updated"}` : time;
217
+ }
218
+ /** Preserve session-local time on narrow terminals without adding composer height. */
219
+ export function fitUsageSummary(fields, width, stale = false) {
220
+ let visible = [...fields];
221
+ const suffix = stale ? " · Not updated" : "";
222
+ const render = (title) => `${title} · ${visible.map(([label, value]) => `${label} ${value}`).join(" · ")}${suffix}`;
223
+ for (const label of ["TPS", "Context", "Cost", "Total"]) {
224
+ if (render("Token Usage").length <= width)
225
+ return render("Token Usage");
226
+ visible = visible.filter(([name]) => name !== label);
227
+ }
228
+ if (render("Token Usage").length <= width)
229
+ return render("Token Usage");
230
+ if (render("Usage").length <= width)
231
+ return render("Usage");
232
+ // Remove the title before clipping any value; time is the final retained field.
233
+ return `${visible.map(([label, value]) => `${label} ${value}`).join(" · ")}${stale ? " · stale" : ""}`.slice(0, Math.max(0, width));
234
+ }
235
+ export const formatCost = (value) => {
236
+ const amount = safe(value);
237
+ if (amount === 0)
238
+ return "$0.00";
239
+ if (amount < 0.001)
240
+ return "<$0.001";
241
+ return `$${amount.toFixed(amount > 0.01 ? 2 : 3)}`;
242
+ };
195
243
  export function formatEstimatedCost(cost, status) {
196
244
  if (status === "empty")
197
245
  return undefined;
@@ -268,7 +316,7 @@ export function summaryRows(summary) {
268
316
  }
269
317
  /** `1 step` reads better than `1 steps` in the dialog's single-line summaries. */
270
318
  export const countLabel = (value, noun) => `${formatTokens(value)} ${noun}${value === 1 ? "" : "s"}`;
271
- export function usageRows(summary, context, performance) {
319
+ export function usageRows(summary, context, performance, runtime) {
272
320
  const t = summary?.tokens;
273
321
  const number = (value) => value === undefined ? "—" : formatTokens(value);
274
322
  const rows = [];
@@ -287,5 +335,7 @@ export function usageRows(summary, context, performance) {
287
335
  if (performance?.ttft !== undefined && Number.isFinite(performance.ttft)) {
288
336
  rows.push(["TTFT", `${(Math.max(0, performance.ttft) / 1_000).toFixed(1)}s`]);
289
337
  }
338
+ if (runtime)
339
+ rows.push(["Run Time", formatRunTime(runtime)]);
290
340
  return rows;
291
341
  }
@@ -1,5 +1,38 @@
1
1
  # 验证记录
2
2
 
3
+ ## 2026-10-09 · Run Time 移至面板末行
4
+
5
+ 将侧边栏与子代理完整浮窗的行序调整为 TPS → TTFT → Run Time;任一性能指标缺失时,Run Time 仍为最后一行。保留原有分组空行,统计口径与子代理单行摘要不变,`/usage` 继续在 Session 区域末行显示耗时。
6
+
7
+ 已通过 `npm run typecheck`、160 项 `npm test`、`npm run build` 与真实 OpenCode 2.0.26 的完整 `npm run test:smoke`。行序测试覆盖只有 TPS、只有 TTFT、无性能数据及无效性能数值;真实终端分别核对侧边栏与子代理浮窗的末行位置。本次未升级版本、提交、推送或发布。
8
+
9
+ 验证产物:`/private/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/opencode/token-usage-smoke-8HSjAl`,其中 `05-message.txt` 显示 TPS、TTFT、Run Time 的最终行序。
10
+
11
+ ## 2026-10-09 · 当前会话累计原生对话耗时(未发布)
12
+
13
+ 环境:macOS、Node.js v22.23.2、npm 10.9.8,插件包仍为 0.4.7、SDK 2.0.24、OpenTUI 0.5.14,真实宿主为 PATH 中的 OpenCode 2.0.26。本次未升级插件或 SDK 版本、未修改价格快照,未提交、推送或发布。
14
+
15
+ 新增 `src/runtime.ts`,复用 `message.list(order: "asc")` 的完整保留历史。口径核对版本固定的宿主源码 [`turnDuration` / `legacyTurns` / `inputIndex`](https://github.com/anomalyco/opencode/blob/v2.0.26/packages/tui/src/routes/session/rows.ts):assistant 完成时间减去上一条 idle 之后首个 user/synthetic 输入的创建时间;无 idle 的旧历史取最近输入,无输入时取 assistant 创建时间,负差值按原生算法截为零。每个轮次只保留最终已完成回答的时长,工具调用等中间 step 不重复计入;未完成的 steer 保留同轮上一条已完成回答的值,后续完成时替换而不是叠加。先累加毫秒,再格式化为毫秒/一位小数秒/分时单位。
16
+
17
+ 累计范围仅为当前查看会话,不额外加入子会话耗时;主会话等待子代理的跨度自然包含在主轮次内。轮次之间的空闲不增长,生成中的回答不做实时计时估算。失败/中断回答有原生完成时间戳时累计;历史完成时间缺失或无效时显示 `—`,不冒充完整的零值或小计。普通读取失败保留上次快照并标注未更新。分叉继承副本不继承累计值;压缩保留历史轮次,暂存回退不扣除,但已提交回退删除消息后相应耗时也移除。
18
+
19
+ 侧边栏在用量与费用之后、TPS/TTFT 之前显示 `Run Time`;子代理摘要加入 `Time`,窄终端省略其他字段以保持单行,点击浮窗复用同一控制器;`/usage` 的 Session 区域独立显示 `Run Time (this session)`。原有 token、费用及性能统计仍覆盖会话树。已移除先前尝试的执行日志适配器、监视器与实时估算,不依赖默认未保存正文的实验性 `session.log`,也不需要服务端插件新增持久化。
20
+
21
+ | 检查 | 结果 |
22
+ | --- | --- |
23
+ | `npm run typecheck` | 通过 |
24
+ | `npm test` | 160 项通过,新增 23 项时间算法、5 项控制器与 3 项格式/行序/窄终端回归测试 |
25
+ | `npm run build` | 通过,产物包含 `dist/runtime.js` 与类型声明 |
26
+ | `npm run test:smoke` | 完整通过,真实打包/隔离安装 + OpenCode 2.0.26 + 本地模拟提供商 |
27
+
28
+ 自动化测试覆盖多轮空闲、重叠页脚去重、首个/最近输入、synthetic/steer、没有输入时的回退、无用量消息、时间缺失及无效日期、失败/中断、压缩、分叉副本及开放轮次边界、累计值重建、会话切换迟到结果隔离、子代理刷新范围、刷新失败保留快照与暂存/已提交回退。时间计算没有额外请求或每秒读取,沿用原有分页与消息快照。
29
+
30
+ 真实 TUI smoke 直接比对首次回答同屏的原生页脚与插件累计值,并以独立的向后扫描 oracle 核对逐轮毫秒合计;另验证空会话为 `0s`、生成中不伪造时长、空闲不增长、累计父轮次不额外加入子代理、切换和新 TUI 从消息历史恢复、子代理浮窗与 48 列单行摘要、中断增加已上报的原生时长,以及真实分叉不继承时间、已完成 compaction 保留升序完整历史。原有 `/usage`、模型切换、TPS/TTFT、未知上下文上限、逐消息定价等全部断言继续通过。
31
+
32
+ 本轮原生耗时的真实集成仅验证 2.0.26;旧历史语义由固定夹具覆盖,没有据此宣称在所有旧宿主重新完成验证。SDK 的已验证宿主记录加入 2.0.26,保留 2.0.11/2.0.22/2.0.24 的既有记录。Node 22 MockTimers 与 OpenTUI 引擎警告保留,没有隐藏或强制绕过。
33
+
34
+ 验证产物:`/private/var/folders/rw/bmx6c8hd737brl55m0_ff_b80000gn/T/opencode/token-usage-smoke-22Efc0`,其中 `05-message.txt` 保存同屏原生页脚与 `Run Time`,`result.json` 保存各会话累计毫秒和宿主版本。
35
+
3
36
  ## 2026-10-09 · 统计钩子的 MCP 等待上限
4
37
 
5
38
  环境:macOS、Node.js v22.23.2、npm 10.9.8,插件包仍为 0.4.6、SDK 2.0.24,真实宿主为 PATH 中的 OpenCode 2.0.25。未升级版本、提交、推送或发布。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chenlongapps/opencode-token-usage",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Session-tree token usage, live performance, context window and estimated cost for OpenCode 2",
5
5
  "keywords": [
6
6
  "opencode",