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 CHANGED
@@ -57,42 +57,44 @@ OpenCode のサイドバーで、現在のセッションツリー(再帰的
57
57
 
58
58
  ## 動作要件
59
59
 
60
- - OpenCode `>= 1.4.3`
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
- ### 方法 1: グローバル TUI 設定を手動で更新する(初回におすすめ)
66
+ TUI 専用パッケージです。グローバルな CLI 設定に追加すると、ターミナルがリモートサーバーへ接続していてもサイドバーは有効なままです。
65
67
 
66
- 以下のグローバル設定ファイルを開くか、新規作成します。
68
+ 次のファイルを開くか、新規作成します。
67
69
 
68
70
  ```text
69
- ~/.config/opencode/tui.json
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/tui.json",
77
- "plugin": [
78
- "opencode-models-usage-plugin/tui"
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
- 保存して OpenCode を再起動すると、npm 経由で自動インストールされます。
85
+ 保存すると、OpenCode が npm からパッケージを入れ、`./tui` エントリ(`Plugin.define`、id は `session-model-usage`)を読み込みます。サイドバーがすぐ出ないときはターミナルクライアントを再起動してください。
87
86
 
88
- ### 方法 2: CLI でワンコマンドインストール
87
+ パッケージは残したままパネルだけ止めたい場合は、同じ配列にプラグイン id を `-` 付きで追加します。
89
88
 
90
- ```bash
91
- opencode plugin opencode-models-usage-plugin --global
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.1.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 `>= 1.4.3`
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
- ### 방법 1: 전역 TUI 설정 파일을 수동으로 수정하기 (처음엔 이 방법 추천)
66
+ TUI 전용 패키지입니다. 전역 CLI 설정에 넣으면 터미널이 원격 서버에 붙어 있어도 사이드바가 그대로 동작합니다.
65
67
 
66
- 다음 전역 설정 파일을 열거나 새로 만드세요.
68
+ 다음 파일을 열거나 새로 만드세요.
67
69
 
68
70
  ```text
69
- ~/.config/opencode/tui.json
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/tui.json",
77
- "plugin": [
78
- "opencode-models-usage-plugin/tui"
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
- 저장한 뒤 OpenCode 를 재시작하면 npm 을 통해 자동으로 설치됩니다.
85
+ 저장하면 OpenCode 가 npm 에서 패키지를 설치하고 `./tui` 엔트리(`Plugin.define`, id 는 `session-model-usage`)를 불러옵니다. 사이드바가 바로 보이지 않으면 터미널 클라이언트를 다시 시작하세요.
87
86
 
88
- ### 방법 2: CLI 한 줄로 설치하기
87
+ 패키지는 두고 패널만 끄려면 같은 배열에 플러그인 id 를 `-` 접두사로 추가합니다.
89
88
 
90
- ```bash
91
- opencode plugin opencode-models-usage-plugin --global
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.1.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 `>= 1.4.3`
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
- ### Method 1: Manually update the global TUI config (recommended for first-time setup)
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 the global config file:
77
+ Open or create:
76
78
 
77
79
  ```text
78
- ~/.config/opencode/tui.json
80
+ ~/.config/opencode/cli.json
79
81
  ```
80
82
 
81
- Add the following:
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/tui.json",
86
- "plugin": [
87
- "opencode-models-usage-plugin/tui"
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 restart OpenCode. The plugin will be installed automatically via npm.
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
- ### Method 2: One-click install via CLI
96
+ To disable the panel without removing the package, prefix the plugin id in the same array:
98
97
 
99
- ```bash
100
- opencode plugin opencode-models-usage-plugin --global
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.1.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 `>= 1.4.3`
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
- ### 方式一:手动修改全局 TUI 配置(推荐第一次使用)
76
+ 这是一个仅 TUI 的插件。把它写进全局 CLI 配置后,即使终端连的是远程服务器,侧边栏也会继续生效。
75
77
 
76
- 打开或新建全局配置文件:
78
+ 打开或新建:
77
79
 
78
80
  ```text
79
- ~/.config/opencode/tui.json
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/tui.json",
87
- "plugin": [
88
- "opencode-models-usage-plugin/tui"
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
- 保存后重启 OpenCode,插件会自动通过 npm 安装。
95
+ 保存后,OpenCode 会从 npm 安装该包,并加载 `./tui` 入口(`Plugin.define`,id 为 `session-model-usage`)。如果侧边栏没有马上出现,重启一次终端客户端。
97
96
 
98
- ### 方式二:CLI 一键安装(最便捷)
97
+ 不想删包、只想关掉面板时,在同一个数组里加上带 `-` 前缀的插件 id:
99
98
 
100
- ```bash
101
- opencode plugin opencode-models-usage-plugin --global
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.1.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": "1.0.1",
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": ">=1.4.3"
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-ai/plugin": "1.4.3",
24
- "@opentui/solid": "0.1.97",
25
- "solid-js": "1.9.11"
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 { getSessionUsage, getTokenTotal, type SessionMessageLike } from "./session-usage"
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({
@@ -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 type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
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 SessionMessageLike, type LiveTpsState } from "./session-usage"
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
- const isRecord = (value: unknown): value is Record<string, unknown> =>
12
- typeof value === "object" && value !== null
43
+ return {
44
+ id: provider.id,
45
+ name: provider.name,
46
+ models: providerModels,
47
+ }
48
+ })
49
+ }
13
50
 
14
- const mapMessage = (message: unknown): SessionMessageLike => {
15
- if (!isRecord(message)) {
16
- return { role: "unknown" }
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
- id: typeof message.id === "string" ? message.id : undefined,
21
- role: typeof message.role === "string" ? message.role : "unknown",
22
- modelID: typeof message.modelID === "string" ? message.modelID : undefined,
23
- providerID: typeof message.providerID === "string" ? message.providerID : undefined,
24
- tokens: isRecord(message.tokens) ? message.tokens : undefined,
25
- cost: typeof message.cost === "number" ? message.cost : undefined,
26
- summary: message.summary,
27
- time: isRecord(message.time)
28
- ? {
29
- created:
30
- typeof message.time.created === "number" ? message.time.created : undefined,
31
- completed:
32
- typeof message.time.completed === "number" ? message.time.completed : undefined,
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
- : undefined,
35
- finish: typeof message.finish === "string" ? message.finish : undefined,
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
- const mapMessages = (messages: readonly unknown[]): SessionMessageLike[] => {
40
- return messages.map((message) => mapMessage(message))
121
+ return ordered
41
122
  }
42
123
 
43
- const useSessionMessages = (api: TuiPluginApi, rootSessionID: Accessor<string>) => {
44
- const [rootMessages, setRootMessages] = createSignal<SessionMessageLike[]>([])
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 [liveInfo, setLiveInfo] = createSignal<{
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 fetchRootMessages = async (sessionID: string): Promise<SessionMessageLike[]> => {
61
- const result = await api.client.session.messages({ sessionID })
62
- if (result.error || !result.data) {
63
- return []
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
- return mapMessages(result.data.map((message) => message.info))
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 fetchChildrenMessages = async (
70
- sessionID: string,
71
- visited = new Set<string>(),
72
- ): Promise<{ messages: SessionMessageLike[]; sessionIDs: Set<string> }> => {
73
- if (visited.has(sessionID)) {
74
- return { messages: [], sessionIDs: visited }
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
- visited.add(sessionID)
78
- const res = await api.client.session.children({ sessionID })
79
- if (res.error || !res.data) {
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
- let msgs: SessionMessageLike[] = []
84
- for (const child of res.data) {
85
- const childID = child.id
86
- if (visited.has(childID)) {
87
- continue
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
- const mRes = await api.client.session.messages({ sessionID: childID })
91
- if (!mRes.error && mRes.data) {
92
- msgs.push(...mapMessages(mRes.data.map((message) => message.info)))
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
- const reloadExtraMessages = async (sessionID: string) => {
101
- const version = ++reloadVersion
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
- if (version !== reloadVersion) {
108
- return
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
- setRootMessages(root)
112
- setExtraMessages(result.messages)
113
- setTrackedSessionIDs(new Set(result.sessionIDs))
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
- setRootMessages([])
119
- setExtraMessages([])
222
+ modelsByMessage.clear()
223
+ setMessages([])
120
224
  setTrackedSessionIDs(new Set([sessionID]))
121
225
  setLiveInfo(null)
122
- void reloadExtraMessages(sessionID)
226
+ void reload(sessionID)
123
227
  })
124
228
 
125
229
  onMount(() => {
126
230
  const unsubs = [
127
- api.event.on("message.part.delta", (ev) => {
128
- const { sessionID, messageID, field, delta } = ev.properties
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
- api.event.on("message.updated", () => {
169
- void reloadExtraMessages(rootSessionID())
234
+ context.data.on("session.reasoning.delta", (event) => {
235
+ noteDelta(event.data.sessionID, event.data.assistantMessageID, event.data.delta)
170
236
  }),
171
- api.event.on("session.status", () => {
172
- void reloadExtraMessages(rootSessionID())
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 approx during streaming
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
- const usageMessages = createMemo(() => {
216
- return [...rootMessages(), ...extraMessages()]
217
- })
218
-
219
- return { usageMessages, liveTps }
304
+ return { usageMessages: messages, liveTps }
220
305
  }
221
306
 
222
- const SessionUsagePanel = (props: { api: TuiPluginApi; sessionID: string }) => {
223
- const activeSessionID = createMemo<string>(() => {
224
- const route = props.api.route.current
225
- if (route.name === "session" && typeof route.params?.sessionID === "string") {
226
- return route.params.sessionID
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
- return props.sessionID
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 { usageMessages, liveTps } = useSessionMessages(props.api, activeSessionID)
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 ? "yellow" : undefined}>● {model.label}</text>
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>■ TPS </text>
249
- <text fg="gray">{model.isLiveTps && model.tps > 0 ? model.tps.toFixed(1) : "—"}</text>
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>■ Context Tokens </text>
253
- <text fg="gray">{model.contextTokens.toLocaleString("en-US")}</text>
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>■ Session Tokens </text>
257
- <text fg="gray">{model.sessionTokens.toLocaleString("en-US")}</text>
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>■ Session Cached </text>
261
- <text fg="gray">{model.sessionCachedTokens.toLocaleString("en-US")}</text>
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>■ spent </text>
265
- <text fg="gray">${model.cost.toFixed(4)}</text>
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 tui: TuiPlugin = async (api) => {
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
- const plugin: TuiPluginModule & { id: string } = {
405
+ export default Plugin.define({
292
406
  id: pluginID,
293
- tui,
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
- export default plugin
421
+ return () => {
422
+ globalStore[globalKey] = false
423
+ unregister()
424
+ }
425
+ },
426
+ })