opencode-models-usage-plugin 1.0.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +21 -19
- package/README.ko.md +21 -19
- package/README.md +21 -19
- package/README.zh-CN.md +21 -19
- package/package.json +12 -5
- package/src/session-usage.test.ts +94 -1
- package/src/session-usage.ts +69 -0
- package/src/tui.tsx +309 -179
package/README.ja.md
CHANGED
|
@@ -57,42 +57,44 @@ OpenCode のサイドバーで、現在のセッションツリー(再帰的
|
|
|
57
57
|
|
|
58
58
|
## 動作要件
|
|
59
59
|
|
|
60
|
-
- OpenCode `>=
|
|
60
|
+
- OpenCode `>= 2.0.0`
|
|
61
|
+
|
|
62
|
+
このパッケージは OpenCode V2 の TUI プラグイン API(`@opencode/plugin`)向けです。V1 の `tui.json` にある `plugin` / `plugin_enabled` では読み込まれません。
|
|
61
63
|
|
|
62
64
|
## インストール
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
TUI 専用パッケージです。グローバルな CLI 設定に追加すると、ターミナルがリモートサーバーへ接続していてもサイドバーは有効なままです。
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
次のファイルを開くか、新規作成します。
|
|
67
69
|
|
|
68
70
|
```text
|
|
69
|
-
~/.config/opencode/
|
|
71
|
+
~/.config/opencode/cli.json
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
`$XDG_CONFIG_HOME` が設定されている場合は `$XDG_CONFIG_HOME/opencode/cli.json` を使います。
|
|
73
75
|
|
|
74
76
|
```json
|
|
75
77
|
{
|
|
76
|
-
"$schema": "https://opencode.ai/
|
|
77
|
-
"
|
|
78
|
-
"opencode-models-usage-plugin
|
|
79
|
-
]
|
|
80
|
-
"plugin_enabled": {
|
|
81
|
-
"session-model-usage": true
|
|
82
|
-
}
|
|
78
|
+
"$schema": "https://opencode.ai/v2/cli.json",
|
|
79
|
+
"plugins": [
|
|
80
|
+
"opencode-models-usage-plugin"
|
|
81
|
+
]
|
|
83
82
|
}
|
|
84
83
|
```
|
|
85
84
|
|
|
86
|
-
|
|
85
|
+
保存すると、OpenCode が npm からパッケージを入れ、`./tui` エントリ(`Plugin.define`、id は `session-model-usage`)を読み込みます。サイドバーがすぐ出ないときはターミナルクライアントを再起動してください。
|
|
87
86
|
|
|
88
|
-
|
|
87
|
+
パッケージは残したままパネルだけ止めたい場合は、同じ配列にプラグイン id を `-` 付きで追加します。
|
|
89
88
|
|
|
90
|
-
```
|
|
91
|
-
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"plugins": [
|
|
92
|
+
"opencode-models-usage-plugin",
|
|
93
|
+
"-session-model-usage"
|
|
94
|
+
]
|
|
95
|
+
}
|
|
92
96
|
```
|
|
93
97
|
|
|
94
|
-
このコマンドでプラグインのインストールとグローバル設定の更新を自動で行えます。
|
|
95
|
-
|
|
96
98
|
## 技術メモ
|
|
97
99
|
|
|
98
100
|
- データは provider/model label 単位で厳密に集約されます
|
|
@@ -104,7 +106,7 @@ opencode plugin opencode-models-usage-plugin --global
|
|
|
104
106
|
## パッケージ情報
|
|
105
107
|
|
|
106
108
|
- npm: `opencode-models-usage-plugin`
|
|
107
|
-
- 現在のバージョン: `0.
|
|
109
|
+
- 現在のバージョン: `2.0.0`
|
|
108
110
|
|
|
109
111
|
## License
|
|
110
112
|
|
package/README.ko.md
CHANGED
|
@@ -57,42 +57,44 @@ OpenCode 사이드바에서 현재 세션 트리(재귀적인 하위 세션 포
|
|
|
57
57
|
|
|
58
58
|
## 요구 사항
|
|
59
59
|
|
|
60
|
-
- OpenCode `>=
|
|
60
|
+
- OpenCode `>= 2.0.0`
|
|
61
|
+
|
|
62
|
+
이 패키지는 OpenCode V2 TUI 플러그인 API(`@opencode/plugin`)용입니다. V1 `tui.json` 의 `plugin` / `plugin_enabled` 로는 로드되지 않습니다.
|
|
61
63
|
|
|
62
64
|
## 설치 방법
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
TUI 전용 패키지입니다. 전역 CLI 설정에 넣으면 터미널이 원격 서버에 붙어 있어도 사이드바가 그대로 동작합니다.
|
|
65
67
|
|
|
66
|
-
다음
|
|
68
|
+
다음 파일을 열거나 새로 만드세요.
|
|
67
69
|
|
|
68
70
|
```text
|
|
69
|
-
~/.config/opencode/
|
|
71
|
+
~/.config/opencode/cli.json
|
|
70
72
|
```
|
|
71
73
|
|
|
72
|
-
|
|
74
|
+
`$XDG_CONFIG_HOME` 이 설정되어 있으면 `$XDG_CONFIG_HOME/opencode/cli.json` 을 사용합니다.
|
|
73
75
|
|
|
74
76
|
```json
|
|
75
77
|
{
|
|
76
|
-
"$schema": "https://opencode.ai/
|
|
77
|
-
"
|
|
78
|
-
"opencode-models-usage-plugin
|
|
79
|
-
]
|
|
80
|
-
"plugin_enabled": {
|
|
81
|
-
"session-model-usage": true
|
|
82
|
-
}
|
|
78
|
+
"$schema": "https://opencode.ai/v2/cli.json",
|
|
79
|
+
"plugins": [
|
|
80
|
+
"opencode-models-usage-plugin"
|
|
81
|
+
]
|
|
83
82
|
}
|
|
84
83
|
```
|
|
85
84
|
|
|
86
|
-
|
|
85
|
+
저장하면 OpenCode 가 npm 에서 패키지를 설치하고 `./tui` 엔트리(`Plugin.define`, id 는 `session-model-usage`)를 불러옵니다. 사이드바가 바로 보이지 않으면 터미널 클라이언트를 다시 시작하세요.
|
|
87
86
|
|
|
88
|
-
|
|
87
|
+
패키지는 두고 패널만 끄려면 같은 배열에 플러그인 id 를 `-` 접두사로 추가합니다.
|
|
89
88
|
|
|
90
|
-
```
|
|
91
|
-
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"plugins": [
|
|
92
|
+
"opencode-models-usage-plugin",
|
|
93
|
+
"-session-model-usage"
|
|
94
|
+
]
|
|
95
|
+
}
|
|
92
96
|
```
|
|
93
97
|
|
|
94
|
-
이 명령은 플러그인을 설치하고 전역 설정도 자동으로 갱신합니다.
|
|
95
|
-
|
|
96
98
|
## 기술 메모
|
|
97
99
|
|
|
98
100
|
- 데이터는 provider/model label 기준으로 엄격하게 집계됩니다
|
|
@@ -104,7 +106,7 @@ opencode plugin opencode-models-usage-plugin --global
|
|
|
104
106
|
## 패키지 정보
|
|
105
107
|
|
|
106
108
|
- npm: `opencode-models-usage-plugin`
|
|
107
|
-
- 현재 버전: `0.
|
|
109
|
+
- 현재 버전: `2.0.0`
|
|
108
110
|
|
|
109
111
|
## License
|
|
110
112
|
|
package/README.md
CHANGED
|
@@ -66,42 +66,44 @@ Whether you are heavily using model routing, complex agent orchestration, or sim
|
|
|
66
66
|
|
|
67
67
|
## Requirements
|
|
68
68
|
|
|
69
|
-
- OpenCode `>=
|
|
69
|
+
- OpenCode `>= 2.0.0`
|
|
70
|
+
|
|
71
|
+
This package targets the OpenCode V2 TUI plugin API (`@opencode/plugin`). It does not load under the V1 `tui.json` `plugin` / `plugin_enabled` config.
|
|
70
72
|
|
|
71
73
|
## Installation
|
|
72
74
|
|
|
73
|
-
|
|
75
|
+
This is a TUI-only package. Add it to the global CLI config so it stays active even when the terminal is connected to a remote server.
|
|
74
76
|
|
|
75
|
-
Open or create
|
|
77
|
+
Open or create:
|
|
76
78
|
|
|
77
79
|
```text
|
|
78
|
-
~/.config/opencode/
|
|
80
|
+
~/.config/opencode/cli.json
|
|
79
81
|
```
|
|
80
82
|
|
|
81
|
-
|
|
83
|
+
When `$XDG_CONFIG_HOME` is set, use `$XDG_CONFIG_HOME/opencode/cli.json` instead.
|
|
82
84
|
|
|
83
85
|
```json
|
|
84
86
|
{
|
|
85
|
-
"$schema": "https://opencode.ai/
|
|
86
|
-
"
|
|
87
|
-
"opencode-models-usage-plugin
|
|
88
|
-
]
|
|
89
|
-
"plugin_enabled": {
|
|
90
|
-
"session-model-usage": true
|
|
91
|
-
}
|
|
87
|
+
"$schema": "https://opencode.ai/v2/cli.json",
|
|
88
|
+
"plugins": [
|
|
89
|
+
"opencode-models-usage-plugin"
|
|
90
|
+
]
|
|
92
91
|
}
|
|
93
92
|
```
|
|
94
93
|
|
|
95
|
-
Save the file and
|
|
94
|
+
Save the file. OpenCode installs the npm package and loads its `./tui` entry (`Plugin.define`, id `session-model-usage`). Restart the terminal client if the sidebar panel does not show up on its own.
|
|
96
95
|
|
|
97
|
-
|
|
96
|
+
To disable the panel without removing the package, prefix the plugin id in the same array:
|
|
98
97
|
|
|
99
|
-
```
|
|
100
|
-
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"plugins": [
|
|
101
|
+
"opencode-models-usage-plugin",
|
|
102
|
+
"-session-model-usage"
|
|
103
|
+
]
|
|
104
|
+
}
|
|
101
105
|
```
|
|
102
106
|
|
|
103
|
-
This command installs the plugin and updates your global config automatically.
|
|
104
|
-
|
|
105
107
|
## Technical Notes
|
|
106
108
|
|
|
107
109
|
- Data is grouped strictly by provider/model label
|
|
@@ -113,7 +115,7 @@ This command installs the plugin and updates your global config automatically.
|
|
|
113
115
|
## Package Info
|
|
114
116
|
|
|
115
117
|
- npm: `opencode-models-usage-plugin`
|
|
116
|
-
- current version: `0.
|
|
118
|
+
- current version: `2.0.0`
|
|
117
119
|
|
|
118
120
|
## License
|
|
119
121
|
|
package/README.zh-CN.md
CHANGED
|
@@ -67,42 +67,44 @@
|
|
|
67
67
|
|
|
68
68
|
## 运行要求
|
|
69
69
|
|
|
70
|
-
- OpenCode `>=
|
|
70
|
+
- OpenCode `>= 2.0.0`
|
|
71
|
+
|
|
72
|
+
本包使用 OpenCode V2 的 TUI 插件 API(`@opencode/plugin`)。V1 的 `tui.json` 里 `plugin` / `plugin_enabled` 不会加载这个版本。
|
|
71
73
|
|
|
72
74
|
## 安装方式
|
|
73
75
|
|
|
74
|
-
|
|
76
|
+
这是一个仅 TUI 的插件。把它写进全局 CLI 配置后,即使终端连的是远程服务器,侧边栏也会继续生效。
|
|
75
77
|
|
|
76
|
-
|
|
78
|
+
打开或新建:
|
|
77
79
|
|
|
78
80
|
```text
|
|
79
|
-
~/.config/opencode/
|
|
81
|
+
~/.config/opencode/cli.json
|
|
80
82
|
```
|
|
81
83
|
|
|
82
|
-
|
|
84
|
+
如果设置了 `$XDG_CONFIG_HOME`,请改用 `$XDG_CONFIG_HOME/opencode/cli.json`。
|
|
83
85
|
|
|
84
86
|
```json
|
|
85
87
|
{
|
|
86
|
-
"$schema": "https://opencode.ai/
|
|
87
|
-
"
|
|
88
|
-
"opencode-models-usage-plugin
|
|
89
|
-
]
|
|
90
|
-
"plugin_enabled": {
|
|
91
|
-
"session-model-usage": true
|
|
92
|
-
}
|
|
88
|
+
"$schema": "https://opencode.ai/v2/cli.json",
|
|
89
|
+
"plugins": [
|
|
90
|
+
"opencode-models-usage-plugin"
|
|
91
|
+
]
|
|
93
92
|
}
|
|
94
93
|
```
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
保存后,OpenCode 会从 npm 安装该包,并加载 `./tui` 入口(`Plugin.define`,id 为 `session-model-usage`)。如果侧边栏没有马上出现,重启一次终端客户端。
|
|
97
96
|
|
|
98
|
-
|
|
97
|
+
不想删包、只想关掉面板时,在同一个数组里加上带 `-` 前缀的插件 id:
|
|
99
98
|
|
|
100
|
-
```
|
|
101
|
-
|
|
99
|
+
```json
|
|
100
|
+
{
|
|
101
|
+
"plugins": [
|
|
102
|
+
"opencode-models-usage-plugin",
|
|
103
|
+
"-session-model-usage"
|
|
104
|
+
]
|
|
105
|
+
}
|
|
102
106
|
```
|
|
103
107
|
|
|
104
|
-
这条命令会自动安装插件,并更新全局配置。
|
|
105
|
-
|
|
106
108
|
## 技术说明
|
|
107
109
|
|
|
108
110
|
- 数据按 provider/model label 严格聚合
|
|
@@ -114,7 +116,7 @@ opencode plugin opencode-models-usage-plugin --global
|
|
|
114
116
|
## 包信息
|
|
115
117
|
|
|
116
118
|
- npm:`opencode-models-usage-plugin`
|
|
117
|
-
- 当前版本:`0.
|
|
119
|
+
- 当前版本:`2.0.0`
|
|
118
120
|
|
|
119
121
|
## License
|
|
120
122
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-models-usage-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Show per-model usage, live TPS, tokens, and cost in the OpenCode sidebar.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -13,19 +13,26 @@
|
|
|
13
13
|
"src"
|
|
14
14
|
],
|
|
15
15
|
"engines": {
|
|
16
|
-
"opencode": ">=
|
|
16
|
+
"opencode": ">=2.0.0"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
19
|
"test": "tsx --test src/**/*.test.ts",
|
|
20
20
|
"typecheck": "tsc --noEmit"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@opencode
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
"@opencode/plugin": "2.2.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@opentui/core": ">=0.6.6",
|
|
27
|
+
"@opentui/solid": ">=0.6.6",
|
|
28
|
+
"solid-js": ">=1.9.0"
|
|
26
29
|
},
|
|
27
30
|
"devDependencies": {
|
|
31
|
+
"@opencode/theme": "2.2.0",
|
|
32
|
+
"@opentui/core": "0.7.0",
|
|
33
|
+
"@opentui/solid": "0.7.0",
|
|
28
34
|
"@types/node": "25.6.0",
|
|
35
|
+
"solid-js": "1.9.12",
|
|
29
36
|
"tsx": "4.21.0",
|
|
30
37
|
"typescript": "6.0.2"
|
|
31
38
|
},
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict"
|
|
2
2
|
import test from "node:test"
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
fromSessionMessage,
|
|
6
|
+
getSessionUsage,
|
|
7
|
+
getTokenTotal,
|
|
8
|
+
type SessionMessageLike,
|
|
9
|
+
} from "./session-usage"
|
|
5
10
|
|
|
6
11
|
test("empty session returns no models", () => {
|
|
7
12
|
assert.deepEqual(getSessionUsage([]), {
|
|
@@ -249,6 +254,94 @@ test("provider catalog also works when providers are stored as an array", () =>
|
|
|
249
254
|
})
|
|
250
255
|
})
|
|
251
256
|
|
|
257
|
+
test("v2 assistant and compaction messages keep the same totals", () => {
|
|
258
|
+
const usage = getSessionUsage([
|
|
259
|
+
fromSessionMessage({
|
|
260
|
+
id: "msg_1",
|
|
261
|
+
type: "assistant",
|
|
262
|
+
model: { id: "gpt-4.1", providerID: "openai" },
|
|
263
|
+
cost: 0.25,
|
|
264
|
+
tokens: {
|
|
265
|
+
input: 11,
|
|
266
|
+
output: 7,
|
|
267
|
+
reasoning: 5,
|
|
268
|
+
cache: { read: 3, write: 2 },
|
|
269
|
+
},
|
|
270
|
+
time: { created: 1000, completed: 2000 },
|
|
271
|
+
finish: "stop",
|
|
272
|
+
}),
|
|
273
|
+
fromSessionMessage({
|
|
274
|
+
id: "user_1",
|
|
275
|
+
type: "user",
|
|
276
|
+
text: "hi",
|
|
277
|
+
time: { created: 500 },
|
|
278
|
+
}),
|
|
279
|
+
fromSessionMessage({
|
|
280
|
+
id: "cmp_1",
|
|
281
|
+
type: "compaction",
|
|
282
|
+
status: "completed",
|
|
283
|
+
model: { id: "gpt-4.1", providerID: "openai" },
|
|
284
|
+
cost: 0.125,
|
|
285
|
+
tokens: {
|
|
286
|
+
input: 4,
|
|
287
|
+
output: 1,
|
|
288
|
+
reasoning: 0,
|
|
289
|
+
cache: { read: 2, write: 0 },
|
|
290
|
+
},
|
|
291
|
+
time: { created: 3000, completed: 4000 },
|
|
292
|
+
}),
|
|
293
|
+
])
|
|
294
|
+
|
|
295
|
+
assert.deepEqual(usage, {
|
|
296
|
+
models: [
|
|
297
|
+
{
|
|
298
|
+
label: "openai/gpt-4.1",
|
|
299
|
+
contextTokens: 7,
|
|
300
|
+
sessionTokens: 35,
|
|
301
|
+
sessionCachedTokens: 5,
|
|
302
|
+
cost: 0.375,
|
|
303
|
+
tps: 0,
|
|
304
|
+
isLiveTps: false,
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
test("v1 message fields still map through", () => {
|
|
311
|
+
assert.deepEqual(
|
|
312
|
+
fromSessionMessage({
|
|
313
|
+
id: "a",
|
|
314
|
+
role: "assistant",
|
|
315
|
+
providerID: "openai",
|
|
316
|
+
modelID: "gpt-4.1",
|
|
317
|
+
cost: 1,
|
|
318
|
+
tokens: {
|
|
319
|
+
input: 1,
|
|
320
|
+
output: 2,
|
|
321
|
+
cache: { read: 3, write: 4 },
|
|
322
|
+
},
|
|
323
|
+
time: { created: 5, completed: 6 },
|
|
324
|
+
finish: "stop",
|
|
325
|
+
}),
|
|
326
|
+
{
|
|
327
|
+
id: "a",
|
|
328
|
+
role: "assistant",
|
|
329
|
+
modelID: "gpt-4.1",
|
|
330
|
+
providerID: "openai",
|
|
331
|
+
tokens: {
|
|
332
|
+
input: 1,
|
|
333
|
+
output: 2,
|
|
334
|
+
reasoning: undefined,
|
|
335
|
+
cache: { read: 3, write: 4 },
|
|
336
|
+
},
|
|
337
|
+
cost: 1,
|
|
338
|
+
summary: undefined,
|
|
339
|
+
time: { created: 5, completed: 6 },
|
|
340
|
+
finish: "stop",
|
|
341
|
+
},
|
|
342
|
+
)
|
|
343
|
+
})
|
|
344
|
+
|
|
252
345
|
test("token helper tolerates missing counters", () => {
|
|
253
346
|
assert.equal(
|
|
254
347
|
getTokenTotal({
|
package/src/session-usage.ts
CHANGED
|
@@ -40,6 +40,75 @@ export interface SessionUsage {
|
|
|
40
40
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
41
41
|
typeof value === "object" && value !== null
|
|
42
42
|
|
|
43
|
+
const readTokens = (value: unknown): TokenUsageLike | undefined => {
|
|
44
|
+
if (!isRecord(value)) return undefined
|
|
45
|
+
|
|
46
|
+
const cache = isRecord(value.cache) ? value.cache : undefined
|
|
47
|
+
return {
|
|
48
|
+
input: typeof value.input === "number" ? value.input : undefined,
|
|
49
|
+
output: typeof value.output === "number" ? value.output : undefined,
|
|
50
|
+
reasoning: typeof value.reasoning === "number" ? value.reasoning : undefined,
|
|
51
|
+
cache: cache
|
|
52
|
+
? {
|
|
53
|
+
read: typeof cache.read === "number" ? cache.read : undefined,
|
|
54
|
+
write: typeof cache.write === "number" ? cache.write : undefined,
|
|
55
|
+
}
|
|
56
|
+
: undefined,
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a V1 (`role` / `modelID` / `providerID`) or V2 session message
|
|
62
|
+
* (`type` / `model: { id, providerID }`) into the shape `getSessionUsage` reads.
|
|
63
|
+
*
|
|
64
|
+
* V2 compaction steps carry the cost and tokens that V1 stored on summary
|
|
65
|
+
* assistant messages, so a compaction with a model is counted as assistant usage.
|
|
66
|
+
*/
|
|
67
|
+
export const fromSessionMessage = (message: unknown): SessionMessageLike => {
|
|
68
|
+
if (!isRecord(message)) {
|
|
69
|
+
return { role: "unknown" }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const model = isRecord(message.model) ? message.model : undefined
|
|
73
|
+
const modelID =
|
|
74
|
+
typeof message.modelID === "string"
|
|
75
|
+
? message.modelID
|
|
76
|
+
: typeof model?.id === "string"
|
|
77
|
+
? model.id
|
|
78
|
+
: undefined
|
|
79
|
+
const providerID =
|
|
80
|
+
typeof message.providerID === "string"
|
|
81
|
+
? message.providerID
|
|
82
|
+
: typeof model?.providerID === "string"
|
|
83
|
+
? model.providerID
|
|
84
|
+
: undefined
|
|
85
|
+
const type = typeof message.type === "string" ? message.type : undefined
|
|
86
|
+
const role =
|
|
87
|
+
typeof message.role === "string"
|
|
88
|
+
? message.role
|
|
89
|
+
: type === "compaction" && modelID
|
|
90
|
+
? "assistant"
|
|
91
|
+
: (type ?? "unknown")
|
|
92
|
+
const time = isRecord(message.time) ? message.time : undefined
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id: typeof message.id === "string" ? message.id : undefined,
|
|
96
|
+
role,
|
|
97
|
+
modelID,
|
|
98
|
+
providerID,
|
|
99
|
+
tokens: readTokens(message.tokens),
|
|
100
|
+
cost: typeof message.cost === "number" ? message.cost : undefined,
|
|
101
|
+
summary: message.summary,
|
|
102
|
+
time: time
|
|
103
|
+
? {
|
|
104
|
+
created: typeof time.created === "number" ? time.created : undefined,
|
|
105
|
+
completed: typeof time.completed === "number" ? time.completed : undefined,
|
|
106
|
+
}
|
|
107
|
+
: undefined,
|
|
108
|
+
finish: typeof message.finish === "string" ? message.finish : undefined,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
43
112
|
const toNumber = (value: unknown): number =>
|
|
44
113
|
typeof value === "number" && Number.isFinite(value) ? value : 0
|
|
45
114
|
|
package/src/tui.tsx
CHANGED
|
@@ -1,183 +1,273 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { Plugin, usePlugin } from "@opencode/plugin/tui"
|
|
4
|
+
import type { JSX } from "@opentui/solid"
|
|
4
5
|
import type { Accessor } from "solid-js"
|
|
5
6
|
import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
|
6
7
|
|
|
7
|
-
import { getSessionUsage, type
|
|
8
|
+
import { fromSessionMessage, getSessionUsage, type LiveTpsState, type SessionMessageLike } from "./session-usage"
|
|
8
9
|
|
|
9
10
|
const pluginID = "session-model-usage"
|
|
11
|
+
const messagePageSize = 200
|
|
12
|
+
const childPageSize = 200
|
|
13
|
+
|
|
14
|
+
type PluginContext = ReturnType<typeof usePlugin>
|
|
15
|
+
|
|
16
|
+
type LiveInfo = {
|
|
17
|
+
sessionID: string
|
|
18
|
+
messageID: string
|
|
19
|
+
modelID: string
|
|
20
|
+
providerID?: string
|
|
21
|
+
startTime: number
|
|
22
|
+
chars: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type KnownModel = {
|
|
26
|
+
modelID: string
|
|
27
|
+
providerID?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const toProviderCatalog = (
|
|
31
|
+
providers: readonly { id: string; name: string }[],
|
|
32
|
+
models: readonly { id: string; modelID: string; providerID: string; name: string }[],
|
|
33
|
+
) => {
|
|
34
|
+
return providers.map((provider) => {
|
|
35
|
+
const providerModels: Record<string, { id: string; name: string }> = {}
|
|
36
|
+
for (const model of models) {
|
|
37
|
+
if (model.providerID !== provider.id) continue
|
|
38
|
+
const entry = { id: model.modelID, name: model.name }
|
|
39
|
+
providerModels[model.modelID] = entry
|
|
40
|
+
if (model.id !== model.modelID) providerModels[model.id] = entry
|
|
41
|
+
}
|
|
10
42
|
|
|
11
|
-
|
|
12
|
-
|
|
43
|
+
return {
|
|
44
|
+
id: provider.id,
|
|
45
|
+
name: provider.name,
|
|
46
|
+
models: providerModels,
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
}
|
|
13
50
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
51
|
+
const listAllMessages = async (
|
|
52
|
+
context: PluginContext,
|
|
53
|
+
sessionID: string,
|
|
54
|
+
): Promise<SessionMessageLike[]> => {
|
|
55
|
+
const collected: SessionMessageLike[] = []
|
|
56
|
+
let cursor: string | undefined
|
|
57
|
+
const seenCursors = new Set<string>()
|
|
58
|
+
|
|
59
|
+
for (;;) {
|
|
60
|
+
const page = await context.client.message.list({
|
|
61
|
+
sessionID,
|
|
62
|
+
limit: messagePageSize,
|
|
63
|
+
...(cursor ? { cursor } : { order: "asc" as const }),
|
|
64
|
+
})
|
|
65
|
+
collected.push(...page.data.map((message) => fromSessionMessage(message)))
|
|
66
|
+
const next = page.cursor.next ?? undefined
|
|
67
|
+
if (!next || seenCursors.has(next)) break
|
|
68
|
+
seenCursors.add(next)
|
|
69
|
+
cursor = next
|
|
17
70
|
}
|
|
18
71
|
|
|
19
|
-
return
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
72
|
+
return collected
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const collectSessionIDs = async (context: PluginContext, rootSessionID: string): Promise<string[]> => {
|
|
76
|
+
const ordered: string[] = []
|
|
77
|
+
const seen = new Set<string>()
|
|
78
|
+
const queue = [rootSessionID]
|
|
79
|
+
|
|
80
|
+
while (queue.length > 0) {
|
|
81
|
+
const sessionID = queue.shift()
|
|
82
|
+
if (!sessionID || seen.has(sessionID)) continue
|
|
83
|
+
seen.add(sessionID)
|
|
84
|
+
ordered.push(sessionID)
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await context.data.session.sync(sessionID)
|
|
88
|
+
} catch {
|
|
89
|
+
// The host store may already know this session.
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let cursor: string | undefined
|
|
93
|
+
const seenCursors = new Set<string>()
|
|
94
|
+
try {
|
|
95
|
+
for (;;) {
|
|
96
|
+
const page = await context.client.session.list({
|
|
97
|
+
parentID: sessionID,
|
|
98
|
+
limit: childPageSize,
|
|
99
|
+
...(cursor ? { cursor } : {}),
|
|
100
|
+
})
|
|
101
|
+
for (const child of page.data) {
|
|
102
|
+
if (seen.has(child.id)) continue
|
|
103
|
+
queue.push(child.id)
|
|
33
104
|
}
|
|
34
|
-
|
|
35
|
-
|
|
105
|
+
const next = page.cursor.next ?? undefined
|
|
106
|
+
if (!next || seenCursors.has(next)) break
|
|
107
|
+
seenCursors.add(next)
|
|
108
|
+
cursor = next
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// family() below still includes sessions the host has already registered.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const sessionID of context.data.session.family(rootSessionID)) {
|
|
116
|
+
if (seen.has(sessionID)) continue
|
|
117
|
+
seen.add(sessionID)
|
|
118
|
+
ordered.push(sessionID)
|
|
36
119
|
}
|
|
37
|
-
}
|
|
38
120
|
|
|
39
|
-
|
|
40
|
-
return messages.map((message) => mapMessage(message))
|
|
121
|
+
return ordered
|
|
41
122
|
}
|
|
42
123
|
|
|
43
|
-
const useSessionMessages = (
|
|
44
|
-
const [
|
|
45
|
-
const [extraMessages, setExtraMessages] = createSignal<SessionMessageLike[]>([])
|
|
124
|
+
const useSessionMessages = (context: PluginContext, rootSessionID: Accessor<string>) => {
|
|
125
|
+
const [messages, setMessages] = createSignal<SessionMessageLike[]>([])
|
|
46
126
|
const [trackedSessionIDs, setTrackedSessionIDs] = createSignal<ReadonlySet<string>>(
|
|
47
127
|
new Set([rootSessionID()]),
|
|
48
128
|
)
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
messageID: string;
|
|
52
|
-
modelID: string;
|
|
53
|
-
providerID?: string;
|
|
54
|
-
startTime: number;
|
|
55
|
-
chars: number;
|
|
56
|
-
} | null>(null)
|
|
129
|
+
const [liveInfo, setLiveInfo] = createSignal<LiveInfo | null>(null)
|
|
130
|
+
const modelsByMessage = new Map<string, KnownModel>()
|
|
57
131
|
|
|
58
132
|
let reloadVersion = 0
|
|
59
133
|
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
134
|
+
const reload = async (sessionID: string) => {
|
|
135
|
+
const version = ++reloadVersion
|
|
136
|
+
let sessionIDs: string[] = []
|
|
137
|
+
let nextMessages: SessionMessageLike[] = []
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
sessionIDs = await collectSessionIDs(context, sessionID)
|
|
141
|
+
const groups = await Promise.all(
|
|
142
|
+
sessionIDs.map(async (id) => {
|
|
143
|
+
try {
|
|
144
|
+
// Full pages, not data.session.message.sync. That call replaces the
|
|
145
|
+
// host transcript cache with a single short page.
|
|
146
|
+
return await listAllMessages(context, id)
|
|
147
|
+
} catch {
|
|
148
|
+
return context.data.session.message.list(id).map((message) => fromSessionMessage(message))
|
|
149
|
+
}
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
nextMessages = groups.flat()
|
|
153
|
+
} catch {
|
|
154
|
+
return
|
|
64
155
|
}
|
|
65
156
|
|
|
66
|
-
|
|
157
|
+
if (version !== reloadVersion) return
|
|
158
|
+
|
|
159
|
+
setMessages(nextMessages)
|
|
160
|
+
setTrackedSessionIDs(new Set(sessionIDs.length > 0 ? sessionIDs : [sessionID]))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const isTracked = (sessionID: string) => {
|
|
164
|
+
if (sessionID === rootSessionID()) return true
|
|
165
|
+
if (trackedSessionIDs().has(sessionID)) return true
|
|
166
|
+
return context.data.session.family(rootSessionID()).includes(sessionID)
|
|
67
167
|
}
|
|
68
168
|
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
169
|
+
const rememberModel = (messageID: string, model: { id: string; providerID: string }) => {
|
|
170
|
+
modelsByMessage.set(messageID, {
|
|
171
|
+
modelID: model.id,
|
|
172
|
+
providerID: model.providerID,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const findModel = (sessionID: string, messageID: string): KnownModel | undefined => {
|
|
177
|
+
const known = modelsByMessage.get(messageID)
|
|
178
|
+
if (known) return known
|
|
179
|
+
|
|
180
|
+
const live = fromSessionMessage(context.data.session.message.get(sessionID, messageID))
|
|
181
|
+
if (live.role === "assistant" && live.modelID) {
|
|
182
|
+
return { modelID: live.modelID, providerID: live.providerID ?? undefined }
|
|
75
183
|
}
|
|
76
184
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return { messages: [], sessionIDs: visited }
|
|
185
|
+
const historical = messages().find((message) => message.id === messageID)
|
|
186
|
+
if (historical?.role === "assistant" && historical.modelID) {
|
|
187
|
+
return { modelID: historical.modelID, providerID: historical.providerID ?? undefined }
|
|
81
188
|
}
|
|
82
189
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
190
|
+
return undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const noteDelta = (sessionID: string, messageID: string, delta: string) => {
|
|
194
|
+
if (!isTracked(sessionID) || delta.length === 0) return
|
|
89
195
|
|
|
90
|
-
|
|
91
|
-
if (
|
|
92
|
-
|
|
196
|
+
setLiveInfo((prev) => {
|
|
197
|
+
if (prev?.messageID === messageID) {
|
|
198
|
+
return { ...prev, chars: prev.chars + delta.length }
|
|
93
199
|
}
|
|
94
|
-
const nested = await fetchChildrenMessages(childID, visited)
|
|
95
|
-
msgs.push(...nested.messages)
|
|
96
|
-
}
|
|
97
|
-
return { messages: msgs, sessionIDs: visited }
|
|
98
|
-
}
|
|
99
200
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const [root, result] = await Promise.all([
|
|
103
|
-
fetchRootMessages(sessionID),
|
|
104
|
-
fetchChildrenMessages(sessionID),
|
|
105
|
-
])
|
|
201
|
+
const model = findModel(sessionID, messageID)
|
|
202
|
+
if (!model) return prev
|
|
106
203
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
204
|
+
return {
|
|
205
|
+
sessionID,
|
|
206
|
+
messageID,
|
|
207
|
+
modelID: model.modelID,
|
|
208
|
+
providerID: model.providerID,
|
|
209
|
+
startTime: Date.now(),
|
|
210
|
+
chars: delta.length,
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
}
|
|
110
214
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
215
|
+
const clearLive = (sessionID: string) => {
|
|
216
|
+
if (!isTracked(sessionID)) return
|
|
217
|
+
setLiveInfo((prev) => (prev?.sessionID === sessionID ? null : prev))
|
|
114
218
|
}
|
|
115
219
|
|
|
116
220
|
createEffect(() => {
|
|
117
221
|
const sessionID = rootSessionID()
|
|
118
|
-
|
|
119
|
-
|
|
222
|
+
modelsByMessage.clear()
|
|
223
|
+
setMessages([])
|
|
120
224
|
setTrackedSessionIDs(new Set([sessionID]))
|
|
121
225
|
setLiveInfo(null)
|
|
122
|
-
void
|
|
226
|
+
void reload(sessionID)
|
|
123
227
|
})
|
|
124
228
|
|
|
125
229
|
onMount(() => {
|
|
126
230
|
const unsubs = [
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (!sessionID || !messageID || typeof delta !== "string") return
|
|
130
|
-
if (!trackedSessionIDs().has(sessionID)) return
|
|
131
|
-
if (field !== "text" && field !== "reasoning") return
|
|
132
|
-
|
|
133
|
-
setLiveInfo(prev => {
|
|
134
|
-
if (prev?.messageID === messageID) {
|
|
135
|
-
return { ...prev, chars: prev.chars + delta.length }
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Try to find the message
|
|
139
|
-
const rootMsgs = mapMessages(api.state.session.messages(rootSessionID()))
|
|
140
|
-
const historicalRootMessages = rootMessages()
|
|
141
|
-
const extra = extraMessages()
|
|
142
|
-
let found = rootMsgs.find(m => m.id === messageID)
|
|
143
|
-
if (!found) {
|
|
144
|
-
found = historicalRootMessages.find(m => m.id === messageID)
|
|
145
|
-
}
|
|
146
|
-
if (!found) {
|
|
147
|
-
found = extra.find(m => m.id === messageID)
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (!found || found.role !== "assistant" || !found.modelID) {
|
|
151
|
-
return prev
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const modelID = found.modelID
|
|
155
|
-
if (typeof modelID !== "string") {
|
|
156
|
-
return prev
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
return {
|
|
160
|
-
messageID,
|
|
161
|
-
modelID,
|
|
162
|
-
providerID: typeof found.providerID === "string" ? found.providerID : undefined,
|
|
163
|
-
startTime: Date.now(),
|
|
164
|
-
chars: delta.length
|
|
165
|
-
}
|
|
166
|
-
})
|
|
231
|
+
context.data.on("session.text.delta", (event) => {
|
|
232
|
+
noteDelta(event.data.sessionID, event.data.assistantMessageID, event.data.delta)
|
|
167
233
|
}),
|
|
168
|
-
|
|
169
|
-
|
|
234
|
+
context.data.on("session.reasoning.delta", (event) => {
|
|
235
|
+
noteDelta(event.data.sessionID, event.data.assistantMessageID, event.data.delta)
|
|
170
236
|
}),
|
|
171
|
-
|
|
172
|
-
|
|
237
|
+
context.data.on("session.step.started", (event) => {
|
|
238
|
+
rememberModel(event.data.assistantMessageID, event.data.model)
|
|
239
|
+
void reload(rootSessionID())
|
|
240
|
+
}),
|
|
241
|
+
context.data.on("session.step.ended", () => {
|
|
242
|
+
void reload(rootSessionID())
|
|
243
|
+
}),
|
|
244
|
+
context.data.on("session.step.failed", () => {
|
|
245
|
+
void reload(rootSessionID())
|
|
246
|
+
}),
|
|
247
|
+
context.data.on("session.usage.updated", () => {
|
|
248
|
+
void reload(rootSessionID())
|
|
249
|
+
}),
|
|
250
|
+
context.data.on("session.status", () => {
|
|
251
|
+
void reload(rootSessionID())
|
|
252
|
+
}),
|
|
253
|
+
context.data.on("session.created", () => {
|
|
254
|
+
void reload(rootSessionID())
|
|
255
|
+
}),
|
|
256
|
+
context.data.on("session.idle", (event) => {
|
|
257
|
+
clearLive(event.data.sessionID)
|
|
258
|
+
}),
|
|
259
|
+
context.data.on("session.execution.succeeded", (event) => {
|
|
260
|
+
clearLive(event.data.sessionID)
|
|
261
|
+
void reload(rootSessionID())
|
|
262
|
+
}),
|
|
263
|
+
context.data.on("session.execution.failed", (event) => {
|
|
264
|
+
clearLive(event.data.sessionID)
|
|
265
|
+
void reload(rootSessionID())
|
|
266
|
+
}),
|
|
267
|
+
context.data.on("session.execution.interrupted", (event) => {
|
|
268
|
+
clearLive(event.data.sessionID)
|
|
269
|
+
void reload(rootSessionID())
|
|
173
270
|
}),
|
|
174
|
-
api.event.on("session.idle", (ev) => {
|
|
175
|
-
if (!trackedSessionIDs().has(ev.properties.sessionID)) {
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
setLiveInfo(null)
|
|
180
|
-
})
|
|
181
271
|
]
|
|
182
272
|
|
|
183
273
|
onCleanup(() => {
|
|
@@ -188,9 +278,8 @@ const useSessionMessages = (api: TuiPluginApi, rootSessionID: Accessor<string>)
|
|
|
188
278
|
})
|
|
189
279
|
})
|
|
190
280
|
|
|
191
|
-
// Derive liveTps from liveInfo
|
|
192
281
|
const [now, setNow] = createSignal(Date.now())
|
|
193
|
-
|
|
282
|
+
|
|
194
283
|
onMount(() => {
|
|
195
284
|
const timer = setInterval(() => setNow(Date.now()), 500)
|
|
196
285
|
onCleanup(() => clearInterval(timer))
|
|
@@ -201,37 +290,61 @@ const useSessionMessages = (api: TuiPluginApi, rootSessionID: Accessor<string>)
|
|
|
201
290
|
if (!info) return null
|
|
202
291
|
|
|
203
292
|
const durationMs = now() - info.startTime
|
|
204
|
-
// Estimate 1 token = 4 chars
|
|
293
|
+
// Estimate 1 token = 4 chars while the reply is still streaming.
|
|
205
294
|
const tpsTokens = Math.ceil(info.chars / 4)
|
|
206
295
|
|
|
207
296
|
return {
|
|
208
297
|
modelID: info.modelID,
|
|
209
298
|
providerID: info.providerID,
|
|
210
299
|
tpsTokens,
|
|
211
|
-
tpsDurationMs: Math.max(durationMs, 1)
|
|
300
|
+
tpsDurationMs: Math.max(durationMs, 1),
|
|
212
301
|
}
|
|
213
302
|
})
|
|
214
303
|
|
|
215
|
-
|
|
216
|
-
return [...rootMessages(), ...extraMessages()]
|
|
217
|
-
})
|
|
218
|
-
|
|
219
|
-
return { usageMessages, liveTps }
|
|
304
|
+
return { usageMessages: messages, liveTps }
|
|
220
305
|
}
|
|
221
306
|
|
|
222
|
-
const SessionUsagePanel = (props: {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
307
|
+
const SessionUsagePanel = (props: { sessionID: string }) => {
|
|
308
|
+
const context = usePlugin()
|
|
309
|
+
const activeSessionID = () => props.sessionID
|
|
310
|
+
const { usageMessages, liveTps } = useSessionMessages(context, activeSessionID)
|
|
311
|
+
const [catalog, setCatalog] = createSignal<ReturnType<typeof toProviderCatalog>>([])
|
|
312
|
+
|
|
313
|
+
const refreshCatalog = async () => {
|
|
314
|
+
const location = context.location ?? context.data.location.default()
|
|
315
|
+
try {
|
|
316
|
+
await Promise.all([
|
|
317
|
+
context.data.location.provider.sync(location),
|
|
318
|
+
context.data.location.model.sync(location),
|
|
319
|
+
])
|
|
320
|
+
} catch {
|
|
321
|
+
// Keep going with whatever the host has already cached.
|
|
227
322
|
}
|
|
228
323
|
|
|
229
|
-
|
|
324
|
+
setCatalog(
|
|
325
|
+
toProviderCatalog(
|
|
326
|
+
context.data.location.provider.list(location) ?? [],
|
|
327
|
+
context.data.location.model.list(location) ?? [],
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
onMount(() => {
|
|
333
|
+
void refreshCatalog()
|
|
334
|
+
const stopProvider = context.data.on("provider.updated", () => {
|
|
335
|
+
void refreshCatalog()
|
|
336
|
+
})
|
|
337
|
+
const stopModel = context.data.on("model.updated", () => {
|
|
338
|
+
void refreshCatalog()
|
|
339
|
+
})
|
|
340
|
+
onCleanup(() => {
|
|
341
|
+
stopProvider()
|
|
342
|
+
stopModel()
|
|
343
|
+
})
|
|
230
344
|
})
|
|
231
345
|
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
const usage = createMemo(() => getSessionUsage(usageMessages(), props.api.state.provider, liveTps()))
|
|
346
|
+
const usage = createMemo(() => getSessionUsage(usageMessages(), catalog(), liveTps()))
|
|
347
|
+
const theme = () => context.theme
|
|
235
348
|
|
|
236
349
|
return (
|
|
237
350
|
<box flexDirection="column">
|
|
@@ -242,27 +355,41 @@ const SessionUsagePanel = (props: { api: TuiPluginApi; sessionID: string }) => {
|
|
|
242
355
|
<For each={usage().models}>
|
|
243
356
|
{(model) => (
|
|
244
357
|
<box flexDirection="column">
|
|
245
|
-
<text fg={model.isLiveTps ?
|
|
358
|
+
<text fg={model.isLiveTps ? theme().text.feedback.warning.base : theme().text.base}>
|
|
359
|
+
● {model.label}
|
|
360
|
+
</text>
|
|
246
361
|
<box flexDirection="column" paddingLeft={2}>
|
|
247
362
|
<box flexDirection="row">
|
|
248
|
-
<text
|
|
249
|
-
|
|
363
|
+
<text fg={theme().text.base} width={18} flexShrink={0}>
|
|
364
|
+
■ TPS
|
|
365
|
+
</text>
|
|
366
|
+
<text fg={theme().text.muted}>
|
|
367
|
+
{model.isLiveTps && model.tps > 0 ? model.tps.toFixed(1) : "—"}
|
|
368
|
+
</text>
|
|
250
369
|
</box>
|
|
251
370
|
<box flexDirection="row">
|
|
252
|
-
<text
|
|
253
|
-
|
|
371
|
+
<text fg={theme().text.base} width={18} flexShrink={0}>
|
|
372
|
+
■ Context Tokens
|
|
373
|
+
</text>
|
|
374
|
+
<text fg={theme().text.muted}>{model.contextTokens.toLocaleString("en-US")}</text>
|
|
254
375
|
</box>
|
|
255
376
|
<box flexDirection="row">
|
|
256
|
-
<text
|
|
257
|
-
|
|
377
|
+
<text fg={theme().text.base} width={18} flexShrink={0}>
|
|
378
|
+
■ Session Tokens
|
|
379
|
+
</text>
|
|
380
|
+
<text fg={theme().text.muted}>{model.sessionTokens.toLocaleString("en-US")}</text>
|
|
258
381
|
</box>
|
|
259
382
|
<box flexDirection="row">
|
|
260
|
-
<text
|
|
261
|
-
|
|
383
|
+
<text fg={theme().text.base} width={18} flexShrink={0}>
|
|
384
|
+
■ Session Cached
|
|
385
|
+
</text>
|
|
386
|
+
<text fg={theme().text.muted}>{model.sessionCachedTokens.toLocaleString("en-US")}</text>
|
|
262
387
|
</box>
|
|
263
388
|
<box flexDirection="row">
|
|
264
|
-
<text
|
|
265
|
-
|
|
389
|
+
<text fg={theme().text.base} width={18} flexShrink={0}>
|
|
390
|
+
■ spent
|
|
391
|
+
</text>
|
|
392
|
+
<text fg={theme().text.muted}>${model.cost.toFixed(4)}</text>
|
|
266
393
|
</box>
|
|
267
394
|
</box>
|
|
268
395
|
</box>
|
|
@@ -273,24 +400,27 @@ const SessionUsagePanel = (props: { api: TuiPluginApi; sessionID: string }) => {
|
|
|
273
400
|
)
|
|
274
401
|
}
|
|
275
402
|
|
|
276
|
-
const
|
|
277
|
-
api.slots.register({
|
|
278
|
-
order: 150,
|
|
279
|
-
slots: {
|
|
280
|
-
sidebar_content(_ctx, props) {
|
|
281
|
-
return (
|
|
282
|
-
<Show when={props.session_id} keyed>
|
|
283
|
-
{(sessionID: string) => <SessionUsagePanel api={api} sessionID={sessionID} />}
|
|
284
|
-
</Show>
|
|
285
|
-
)
|
|
286
|
-
},
|
|
287
|
-
},
|
|
288
|
-
})
|
|
289
|
-
}
|
|
403
|
+
const globalKey = Symbol.for(`opencode-plugin-${pluginID}`)
|
|
290
404
|
|
|
291
|
-
|
|
405
|
+
export default Plugin.define({
|
|
292
406
|
id: pluginID,
|
|
293
|
-
|
|
294
|
-
}
|
|
407
|
+
setup(context) {
|
|
408
|
+
const globalStore = globalThis as { [key: symbol]: boolean | undefined }
|
|
409
|
+
if (globalStore[globalKey]) return
|
|
410
|
+
globalStore[globalKey] = true
|
|
411
|
+
|
|
412
|
+
const unregister: () => void = context.ui.slot({
|
|
413
|
+
append: "sidebar.content",
|
|
414
|
+
render: ({ sessionID }): JSX.Element => (
|
|
415
|
+
<Show when={sessionID} keyed>
|
|
416
|
+
{(id: string) => <SessionUsagePanel sessionID={id} />}
|
|
417
|
+
</Show>
|
|
418
|
+
),
|
|
419
|
+
})
|
|
295
420
|
|
|
296
|
-
|
|
421
|
+
return () => {
|
|
422
|
+
globalStore[globalKey] = false
|
|
423
|
+
unregister()
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
})
|