opencode-tui-stats 0.4.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # opencode-tui-stats
2
2
 
3
- A TUI plugin for OpenCode 1.x (v1) that shows a single line — `TPS | AVG | TTFT` — on the right side of the session prompt.
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`: live generation rate within the most recent ~5-second streaming window (tokens/sec); shows `-` when the session is idle or data is stale.
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 of completed messages in this session (seconds).
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 插件,在会话提示区右侧显示一行 `TPS | AVG | TTFT`。
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 秒流式窗口内的实时生成速度(token/秒);会话空闲或数据过期时显示 `-`。
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.1",
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 = {
@@ -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
- const sessionLiveRate = (sessionID: string, key: string) => {
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 undefined
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 undefined
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
- return formatRate(tokens / (activeDuration(samples, now) / 1_000))
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 undefined
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: isActive ? liveRateFor(subtree, key) : undefined,
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] ?? (tracker.models[sessionID] = { order: [], aggregates: {}, messageModels: new Map() })
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
@@ -65,6 +65,9 @@ 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>
@@ -78,14 +81,23 @@ function ModelRowView(props: { api: TuiPluginApi; row: ModelRow; expanded: boole
78
81
  </text>
79
82
  <Show when={props.expanded}>
80
83
  <text fg={theme().textMuted} wrapMode="none" truncate>
81
- {`${DETAIL_INDENT}AVG ${rateCell(props.row.avg)}${
82
- props.row.tps === undefined ? "" : ` · TPS ${rateCell(props.row.tps)}`
83
- }`}
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>
84
95
  </text>
85
96
  <text fg={theme().textMuted} wrapMode="none" truncate>
86
- {`${DETAIL_INDENT}TTFT ${ttftCell(props.row.ttft)} · CACHE ${cacheCell(props.row.cache)} · ${
87
- props.row.cost ?? "—"
88
- }`}
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 ?? "—"}`}
89
101
  </text>
90
102
  </Show>
91
103
  </box>
@@ -156,7 +168,7 @@ function ModelPanelView(props: {
156
168
  return (
157
169
  <box flexDirection="column">
158
170
  <text fg={props.api.theme.current.text} wrapMode="none" truncate>
159
- <b>Performance · observed</b>
171
+ <b>Model-Performance · observed</b>
160
172
  </text>
161
173
  <Show when={panel().rows.length === 0}>
162
174
  <text fg={props.api.theme.current.textMuted} wrapMode="none" truncate>