opencode-tui-stats 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +17 -3
- package/README.md +17 -3
- package/assets/demo.gif +0 -0
- package/package.json +1 -1
- package/tracker.ts +26 -10
- package/tui.tsx +26 -12
package/README.en.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# opencode-tui-stats
|
|
2
2
|
|
|
3
|
-
A TUI plugin for OpenCode 1.x (v1)
|
|
3
|
+
A TUI plugin for OpenCode 1.x (v1), mounted at the bottom of the right sidebar (slot `sidebar_footer`), with the panel title `Model-Performance · observed`. Each model occupies one row that can be collapsed/expanded by mouse click.
|
|
4
4
|
|
|
5
5
|
## Demo
|
|
6
6
|
|
|
@@ -18,11 +18,25 @@ This command writes the plugin into opencode's TUI config. You can also install
|
|
|
18
18
|
|
|
19
19
|
- opencode ≥ 1.3.14
|
|
20
20
|
|
|
21
|
+
## Collapsing & subagents
|
|
22
|
+
|
|
23
|
+
- Each model occupies one row that can be collapsed/expanded by mouse click (`▼`/`▶`); rows are collapsed by default, and the most recently used model starts expanded.
|
|
24
|
+
- Collapsed: `▶ [↳ ]provider/model` (redundant same-provider prefix omitted; provider dimmed, model name bright).
|
|
25
|
+
- Expanded:
|
|
26
|
+
- `AVG …t/s · TPS …t/s` (TPS shown only when a value exists; the final value is kept and dimmed after generation ends)
|
|
27
|
+
- `TTFT …s · CACHE …%`
|
|
28
|
+
- `$…` (cost on its own line)
|
|
29
|
+
- Models from subagents (sub-sessions) are merged into the same panel; rows originating from a subagent are prefixed with `↳`, and the same model (parent/child) is merged and summed.
|
|
30
|
+
- Panel footer: `Session CACHE …%` (aggregated across the whole session + subagent tree, 1 decimal place); `retries` is shown only when >0.
|
|
31
|
+
|
|
21
32
|
## Metrics
|
|
22
33
|
|
|
23
|
-
- `TPS`:
|
|
34
|
+
- `TPS`: generation rate within the most recent ~5-second streaming window (tokens/sec); the final value is frozen and dimmed after the stream stops.
|
|
24
35
|
- `AVG`: average generation rate of completed messages in this session (tokens/sec).
|
|
25
|
-
- `TTFT`: average time to first token
|
|
36
|
+
- `TTFT`: average time to first token (seconds, approximate, includes queue/retry wait).
|
|
37
|
+
- `CACHE`: prompt cache hit rate = `cache.read / (input + cache.read + cache.write)`, 1 decimal place.
|
|
38
|
+
- `COST`: cumulative session cost (aggregated per model/session).
|
|
39
|
+
- `RETRY`: retry count, derived from the attempt reported by the provider.
|
|
26
40
|
|
|
27
41
|
## License
|
|
28
42
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# opencode-tui-stats
|
|
2
2
|
|
|
3
|
-
OpenCode 1.x(v1)的 TUI
|
|
3
|
+
OpenCode 1.x(v1)的 TUI 插件,安装在右侧栏底部(slot `sidebar_footer`),面板标题为 `Model-Performance · observed`。每个模型占一行,鼠标点击可折叠/展开。
|
|
4
4
|
|
|
5
5
|
## 演示
|
|
6
6
|
|
|
@@ -18,11 +18,25 @@ opencode plugin opencode-tui-stats --global
|
|
|
18
18
|
|
|
19
19
|
- opencode ≥ 1.3.14
|
|
20
20
|
|
|
21
|
+
## 折叠与子会话
|
|
22
|
+
|
|
23
|
+
- 每个模型占一行,鼠标点击可折叠/展开(`▼`/`▶`);默认折叠,最近使用的模型默认展开。
|
|
24
|
+
- 折叠态:`▶ [↳ ]provider/model`(同 provider 前缀省略;provider 暗色、模型名亮色)。
|
|
25
|
+
- 展开态:
|
|
26
|
+
- `AVG …t/s · TPS …t/s`(TPS 仅在有值时显示;生成结束后保留最后值并变暗)
|
|
27
|
+
- `TTFT …s · CACHE …%`
|
|
28
|
+
- `$…`(成本单独一行)
|
|
29
|
+
- 子会话(子 agent)的模型会并入同一面板,来自子会话的行前缀 `↳`;同一模型(父/子)合并求和。
|
|
30
|
+
- 面板底部:`Session CACHE …%`(整个会话 + 子会话树聚合,1 位小数);`retries` 仅在 >0 时显示。
|
|
31
|
+
|
|
21
32
|
## 指标说明
|
|
22
33
|
|
|
23
|
-
- `TPS`:最近约 5
|
|
34
|
+
- `TPS`:最近约 5 秒流式窗口内的生成速度(token/秒);停止后冻结最后值并变暗。
|
|
24
35
|
- `AVG`:本会话已完成消息的平均生成速度(token/秒)。
|
|
25
|
-
- `TTFT
|
|
36
|
+
- `TTFT`:平均首字延迟(秒,近似值,包含排队/重试等待)。
|
|
37
|
+
- `CACHE`:提示词缓存命中率 = `cache.read / (input + cache.read + cache.write)`,1 位小数。
|
|
38
|
+
- `COST`:会话累计成本(按模型/会话聚合)。
|
|
39
|
+
- `RETRY`:按 provider 上报的 attempt 统计的重试次数。
|
|
26
40
|
|
|
27
41
|
## License
|
|
28
42
|
|
package/assets/demo.gif
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "opencode-tui-stats",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.2",
|
|
5
5
|
"description": "OpenCode 1 TUI plugin that shows live TPS, average TPS, and average time to first token in the session prompt.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"exports": {
|
package/tracker.ts
CHANGED
|
@@ -53,6 +53,9 @@ export type SessionModels = {
|
|
|
53
53
|
aggregates: Record<string, ModelAggregate>
|
|
54
54
|
/** `messageID -> providerID/modelID`, used to attribute live samples to a model. */
|
|
55
55
|
messageModels: Map<string, string>
|
|
56
|
+
/** Last measured live rate per model key, kept after the stream goes idle/stale so the
|
|
57
|
+
* final reading stays visible instead of disappearing. */
|
|
58
|
+
lastRate: Record<string, string>
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
export type Tracker = {
|
|
@@ -147,7 +150,7 @@ export function averageTtft(average: SessionAverage | undefined) {
|
|
|
147
150
|
/** Cache hit percentage (e.g. `31%`) or `undefined` when nothing promptable has been seen. */
|
|
148
151
|
export function cacheText(stats: { cacheRead: number; cachePrompt: number } | undefined) {
|
|
149
152
|
if (!stats || stats.cachePrompt <= 0) return undefined
|
|
150
|
-
return `${
|
|
153
|
+
return `${((stats.cacheRead / stats.cachePrompt) * 100).toFixed(1)}%`
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
export function costText(stats: { cost: number; steps: number } | undefined) {
|
|
@@ -182,7 +185,10 @@ export type ModelRow = {
|
|
|
182
185
|
active: boolean
|
|
183
186
|
/** True when any descendant (subagent) session contributed data to this row. */
|
|
184
187
|
subagent?: boolean
|
|
188
|
+
/** Last measured live rate, or `undefined` when this model has never streamed. */
|
|
185
189
|
tps?: string
|
|
190
|
+
/** True while `tps` is backed by an active stream; false for a frozen final value. */
|
|
191
|
+
tpsLive: boolean
|
|
186
192
|
avg?: string
|
|
187
193
|
ttft?: string
|
|
188
194
|
cache?: string
|
|
@@ -323,10 +329,13 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
323
329
|
bump()
|
|
324
330
|
}
|
|
325
331
|
|
|
326
|
-
/** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping.
|
|
327
|
-
|
|
332
|
+
/** Live rate restricted to samples attributed to `key` via the `messageID -> model` mapping.
|
|
333
|
+
* When the session is idle or the samples are stale, the last measured value is frozen and
|
|
334
|
+
* returned with `live: false` instead of disappearing. */
|
|
335
|
+
const sessionLiveRate = (sessionID: string, key: string): { value?: string; live: boolean } => {
|
|
336
|
+
const frozen = { value: tracker.models[sessionID]?.lastRate[key], live: false }
|
|
328
337
|
const status = api.state.session.status(sessionID)
|
|
329
|
-
if (!status || status.type === "idle") return
|
|
338
|
+
if (!status || status.type === "idle") return frozen
|
|
330
339
|
|
|
331
340
|
const now = Date.now()
|
|
332
341
|
const mapping = tracker.models[sessionID]?.messageModels
|
|
@@ -334,10 +343,14 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
334
343
|
(sample) => now - sample.at <= STREAM_WINDOW_MS && mapping?.get(sample.messageID) === key,
|
|
335
344
|
)
|
|
336
345
|
const last = samples[samples.length - 1]
|
|
337
|
-
if (!last || now - last.at > LIVE_STALE_MS) return
|
|
346
|
+
if (!last || now - last.at > LIVE_STALE_MS) return frozen
|
|
338
347
|
|
|
339
348
|
const tokens = samples.reduce((sum, sample) => sum + sample.tokens, 0)
|
|
340
|
-
|
|
349
|
+
const value = formatRate(tokens / (activeDuration(samples, now) / 1_000))
|
|
350
|
+
if (value === undefined) return frozen
|
|
351
|
+
const models = tracker.models[sessionID]
|
|
352
|
+
if (models) models.lastRate[key] = value
|
|
353
|
+
return { value, live: true }
|
|
341
354
|
}
|
|
342
355
|
|
|
343
356
|
/** Fold a session's parent from `session.created`/`session.updated`, or lazily from state.
|
|
@@ -414,14 +427,14 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
414
427
|
/** Live rate for a merged row: the parent and its subagents share one row, so only the
|
|
415
428
|
* session/model pair that most recently saw activity contributes. Concurrent streams in a
|
|
416
429
|
* parent and a child are therefore approximated by the newer one instead of being summed. */
|
|
417
|
-
const liveRateFor = (sessions: string[], key: string) => {
|
|
430
|
+
const liveRateFor = (sessions: string[], key: string): { value?: string; live: boolean } => {
|
|
418
431
|
let latest: { sessionID: string; at: number } | undefined
|
|
419
432
|
for (const sessionID of sessions) {
|
|
420
433
|
const aggregate = tracker.models[sessionID]?.aggregates[key]
|
|
421
434
|
if (!aggregate) continue
|
|
422
435
|
if (!latest || aggregate.lastAt >= latest.at) latest = { sessionID, at: aggregate.lastAt }
|
|
423
436
|
}
|
|
424
|
-
if (!latest) return
|
|
437
|
+
if (!latest) return { live: false }
|
|
425
438
|
return sessionLiveRate(latest.sessionID, key)
|
|
426
439
|
}
|
|
427
440
|
|
|
@@ -482,12 +495,14 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
482
495
|
const rows = visible.map<ModelRow>((key) => {
|
|
483
496
|
const aggregate = aggregates[key]
|
|
484
497
|
const isActive = key === active
|
|
498
|
+
const tps = isActive ? liveRateFor(subtree, key) : { value: undefined, live: false }
|
|
485
499
|
return {
|
|
486
500
|
key,
|
|
487
501
|
label: key,
|
|
488
502
|
active: isActive,
|
|
489
503
|
subagent: subagentModels.has(key),
|
|
490
|
-
tps:
|
|
504
|
+
tps: tps.value,
|
|
505
|
+
tpsLive: tps.live,
|
|
491
506
|
avg: averageRate(aggregate),
|
|
492
507
|
ttft: averageTtft(aggregate),
|
|
493
508
|
cache: cacheText(aggregate),
|
|
@@ -533,7 +548,8 @@ export function createTracker(api: TuiPluginApi, onChange: () => void): TrackerH
|
|
|
533
548
|
// Observe the model as soon as a message appears so the sidebar can attribute live samples
|
|
534
549
|
// and mark the most recently used model as active.
|
|
535
550
|
const models =
|
|
536
|
-
tracker.models[sessionID] ??
|
|
551
|
+
tracker.models[sessionID] ??
|
|
552
|
+
(tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map(), lastRate: {} })
|
|
537
553
|
touchModel(models, key)
|
|
538
554
|
models.active = key
|
|
539
555
|
models.aggregates[key] = { ...models.aggregates[key], lastAt: Date.now() }
|
package/tui.tsx
CHANGED
|
@@ -14,10 +14,10 @@ const DETAIL_INDENT = " "
|
|
|
14
14
|
/** Fixed cells keep every label on the same column across rows without measuring the panel. */
|
|
15
15
|
const RATE_CELL = 4
|
|
16
16
|
const TTFT_CELL = 6
|
|
17
|
-
const CACHE_CELL =
|
|
17
|
+
const CACHE_CELL = 6
|
|
18
18
|
|
|
19
|
-
/** Split `provider/model` so the provider can be
|
|
20
|
-
* first slash separates the provider. When the model already starts with the provider name
|
|
19
|
+
/** Split `provider/model` so the provider can be de-emphasised. Model ids may contain slashes, so
|
|
20
|
+
* only the first slash separates the provider. When the model already starts with the provider name
|
|
21
21
|
* (`deepseek/deepseek-flash`) the redundant prefix is dropped. */
|
|
22
22
|
function splitLabel(label: string) {
|
|
23
23
|
const slash = label.indexOf("/")
|
|
@@ -39,7 +39,7 @@ function ttftCell(value: string | undefined) {
|
|
|
39
39
|
function cacheCell(value: string | undefined) {
|
|
40
40
|
if (value === undefined) return "—".padStart(CACHE_CELL)
|
|
41
41
|
const percent = value.endsWith("%") ? value.slice(0, -1) : value
|
|
42
|
-
return `${percent.padStart(
|
|
42
|
+
return `${percent.padStart(5)}%`
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
/** A single model row: fold marker + summary line, plus its detail lines when expanded. */
|
|
@@ -65,25 +65,39 @@ function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boole
|
|
|
65
65
|
return (
|
|
66
66
|
<box flexDirection="column" onMouseDown={handleMouseDown} onMouseUp={handleMouseUp}>
|
|
67
67
|
<text wrapMode="none" truncate>
|
|
68
|
+
{/* The most recently used model gets a green dot; other rows keep a blank slot so
|
|
69
|
+
* names stay aligned without spending extra width on a second marker. */}
|
|
70
|
+
<span style={{ fg: theme().success } as Record<string, unknown>}>{props.row.active ? "● " : " "}</span>
|
|
68
71
|
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>{`${props.expanded ? "▼" : "▶"} `}</span>
|
|
69
72
|
<Show when={props.row.subagent}>
|
|
70
73
|
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>{"↳ "}</span>
|
|
71
74
|
</Show>
|
|
72
75
|
<Show when={parts().provider}>
|
|
73
|
-
|
|
76
|
+
{/* Dimmed foreground sits between the model name and muted metadata without borrowing
|
|
77
|
+
* a status colour, so the provider reads as its own tier even as themes change. */}
|
|
78
|
+
<span style={{ fg: theme().text, dim: true } as Record<string, unknown>}>{parts().provider}</span>
|
|
74
79
|
</Show>
|
|
75
80
|
<span style={{ fg: theme().text } as Record<string, unknown>}>{parts().model}</span>
|
|
76
81
|
</text>
|
|
77
82
|
<Show when={props.expanded}>
|
|
78
83
|
<text fg={theme().textMuted} wrapMode="none" truncate>
|
|
79
|
-
{
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>
|
|
85
|
+
{`${DETAIL_INDENT}AVG ${rateCell(props.row.avg)}`}
|
|
86
|
+
</span>
|
|
87
|
+
<Show when={props.row.tps !== undefined}>
|
|
88
|
+
<span style={{ fg: theme().textMuted } as Record<string, unknown>}>{" · TPS "}</span>
|
|
89
|
+
{/* A frozen final reading stays visible but is dimmed to distinguish it from a
|
|
90
|
+
* value backed by an active stream. */}
|
|
91
|
+
<span style={{ fg: theme().textMuted, dim: !props.row.tpsLive } as Record<string, unknown>}>
|
|
92
|
+
{rateCell(props.row.tps)}
|
|
93
|
+
</span>
|
|
94
|
+
</Show>
|
|
82
95
|
</text>
|
|
83
96
|
<text fg={theme().textMuted} wrapMode="none" truncate>
|
|
84
|
-
{`${DETAIL_INDENT}TTFT ${ttftCell(props.row.ttft)} · CACHE ${cacheCell(props.row.cache)}
|
|
85
|
-
|
|
86
|
-
|
|
97
|
+
{`${DETAIL_INDENT}TTFT ${ttftCell(props.row.ttft)} · CACHE ${cacheCell(props.row.cache)}`}
|
|
98
|
+
</text>
|
|
99
|
+
<text fg={theme().textMuted} wrapMode="none" truncate>
|
|
100
|
+
{`${DETAIL_INDENT}${props.row.cost ?? "—"}`}
|
|
87
101
|
</text>
|
|
88
102
|
</Show>
|
|
89
103
|
</box>
|
|
@@ -154,7 +168,7 @@ function ModelPanelView(props: {
|
|
|
154
168
|
return (
|
|
155
169
|
<box flexDirection="column">
|
|
156
170
|
<text fg={props.api.theme.current.text} wrapMode="none" truncate>
|
|
157
|
-
<b>Performance · observed</b>
|
|
171
|
+
<b>Model-Performance · observed</b>
|
|
158
172
|
</text>
|
|
159
173
|
<Show when={panel().rows.length === 0}>
|
|
160
174
|
<text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>
|