oc-metricboard 0.1.4

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.
@@ -0,0 +1,159 @@
1
+ import { readFileSync, watch } from "node:fs"
2
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
3
+ import { homedir } from "node:os"
4
+ import { basename, dirname, join } from "node:path"
5
+ import { parse, stringify } from "comment-json"
6
+
7
+ // Generic preferences file I/O for OpenCode TUI plugins.
8
+ // Plugin-specific types, defaults, and validation live in tui-preferences.ts.
9
+
10
+ export const TUI_PREFS_FILE_ENV = "OPENCODE_TUI_PREFERENCES_FILE"
11
+ const FILE_NAME = "tui-preferences.jsonc"
12
+
13
+ export function getTuiPreferencesFile(): string {
14
+ const override = process.env[TUI_PREFS_FILE_ENV]
15
+ if (override) return override
16
+ const configDir =
17
+ process.env.OPENCODE_CONFIG_DIR ||
18
+ join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "opencode")
19
+ return join(configDir, FILE_NAME)
20
+ }
21
+
22
+ export function isRecord(value: unknown): value is Record<string, unknown> {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value)
24
+ }
25
+
26
+ // Tolerant read: a missing file, parse error, or non-object root all resolve
27
+ // to {} so the sidebar never crashes on hand-edited content. Never throws.
28
+ export async function readTuiPreferencesFile(): Promise<Record<string, unknown>> {
29
+ try {
30
+ const raw = await readFile(getTuiPreferencesFile(), "utf8")
31
+ if (raw.trim() === "") return {}
32
+ const root: unknown = parse(raw)
33
+ return isRecord(root) ? (root as Record<string, unknown>) : {}
34
+ } catch {
35
+ return {}
36
+ }
37
+ }
38
+
39
+ // Synchronous tolerant read — used once at slot mount to seed the initial
40
+ // collapse state and effective order WITHOUT a frame of async flicker.
41
+ export function readTuiPreferencesFileSync(): Record<string, unknown> {
42
+ try {
43
+ const raw = readFileSync(getTuiPreferencesFile(), "utf8")
44
+ if (raw.trim() === "") return {}
45
+ const root: unknown = parse(raw)
46
+ return isRecord(root) ? (root as Record<string, unknown>) : {}
47
+ } catch {
48
+ return {}
49
+ }
50
+ }
51
+
52
+ const TEMPLATE = `// Shared preferences for OpenCode TUI plugins.
53
+ // One top-level key per plugin (short name). See each plugin's README for its
54
+ // supported settings. This file is safe to hand-edit; plugins update individual
55
+ // keys and preserve the rest (values and comments).
56
+ {}
57
+ `
58
+
59
+ type JsonValue = string | number | boolean | null
60
+
61
+ function setDeep(root: Record<string, unknown>, path: string[], value: JsonValue): boolean {
62
+ let node: Record<string, unknown> = root
63
+ for (let i = 0; i < path.length - 1; i += 1) {
64
+ const key = path[i]
65
+ const child = node[key]
66
+ if (child === undefined || child === null) {
67
+ node[key] = {}
68
+ } else if (!isRecord(child)) {
69
+ return false
70
+ }
71
+ node = node[key] as Record<string, unknown>
72
+ }
73
+ node[path[path.length - 1]] = value
74
+ return true
75
+ }
76
+
77
+ async function writePreference(pluginKey: string, path: string[], value: JsonValue): Promise<void> {
78
+ const file = getTuiPreferencesFile()
79
+ await mkdir(dirname(file), { recursive: true })
80
+ let text: string
81
+ try {
82
+ text = await readFile(file, "utf8")
83
+ } catch {
84
+ text = ""
85
+ }
86
+ if (text.trim() === "") text = TEMPLATE
87
+
88
+ let root: unknown
89
+ try {
90
+ root = parse(text)
91
+ } catch {
92
+ return
93
+ }
94
+ if (!isRecord(root)) root = {}
95
+ if (!setDeep(root as Record<string, unknown>, [pluginKey, ...path], value)) {
96
+ return
97
+ }
98
+
99
+ const next = `${stringify(root, null, 2)}\n`
100
+ const tmp = `${file}.${process.pid}.tmp`
101
+ await writeFile(tmp, next, "utf8")
102
+ await rename(tmp, file)
103
+ }
104
+
105
+ let writeChain: Promise<void> = Promise.resolve()
106
+
107
+ // Writes are serialized on a promise chain: each update re-reads the file,
108
+ // applies a comment-preserving edit to one property, and replaces the file
109
+ // atomically (temp + rename in the same directory).
110
+ export function queueTuiPreferenceUpdate(
111
+ pluginKey: string,
112
+ path: string[],
113
+ value: JsonValue,
114
+ ): Promise<void> {
115
+ writeChain = writeChain.then(() => writePreference(pluginKey, path, value)).catch(() => {})
116
+ return writeChain
117
+ }
118
+
119
+ const WATCH_DEBOUNCE_MS = 150
120
+
121
+ // Watches the DIRECTORY, not the file: editors and our own atomic writes
122
+ // replace the file via rename, which kills file-level watchers.
123
+ export function watchTuiPreferences(onChange: () => void): () => void {
124
+ const file = getTuiPreferencesFile()
125
+ const name = basename(file)
126
+ let timer: ReturnType<typeof setTimeout> | null = null
127
+ let lastSeen: string | null = null
128
+ void readFile(file, "utf8")
129
+ .then((text) => {
130
+ if (lastSeen === null) lastSeen = text
131
+ })
132
+ .catch(() => {})
133
+ try {
134
+ const watcher = watch(dirname(file), (_event, filename) => {
135
+ const isOurs =
136
+ filename === name ||
137
+ (filename?.startsWith(`${name}.`) && filename.endsWith(".tmp"))
138
+ if (filename != null && !isOurs) return
139
+ if (timer) clearTimeout(timer)
140
+ timer = setTimeout(() => {
141
+ timer = null
142
+ void readFile(file, "utf8")
143
+ .catch(() => null)
144
+ .then((text) => {
145
+ if (text === null) return
146
+ if (text === lastSeen) return
147
+ lastSeen = text
148
+ onChange()
149
+ })
150
+ }, WATCH_DEBOUNCE_MS)
151
+ })
152
+ return () => {
153
+ if (timer) clearTimeout(timer)
154
+ watcher.close()
155
+ }
156
+ } catch {
157
+ return () => {}
158
+ }
159
+ }
package/src/tui.tsx ADDED
@@ -0,0 +1,60 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ /** @jsxRuntime automatic */
3
+ import type { TuiPlugin, TuiPluginApi, TuiPluginMeta } from "@opencode-ai/plugin/tui"
4
+ import type { PluginOptions } from "@opencode-ai/plugin"
5
+ import { createCollector } from "./collector"
6
+ import { getConfig } from "./config"
7
+ import { log } from "./logger"
8
+ import { SidebarMetrics } from "./components/SidebarMetrics"
9
+ import {
10
+ computeEffectiveOrder,
11
+ createMetricsSidebarController,
12
+ DEFAULT_SLOT_ORDER,
13
+ PLUGIN_KEY,
14
+ resolveMetricsPrefs,
15
+ } from "./tui-preferences"
16
+ import { readTuiPreferencesFileSync } from "./tui-prefs-io"
17
+
18
+ const plugin: TuiPlugin = async (api: TuiPluginApi, _options: PluginOptions | undefined, _meta: TuiPluginMeta) => {
19
+ const config = getConfig()
20
+ const collector = createCollector(api, config, log)
21
+
22
+ // Sync-read preferences at slot mount so the sidebar renders at its final
23
+ // order and collapse state on the first paint (no async flicker).
24
+ const seedRoot = readTuiPreferencesFileSync()
25
+ const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER)
26
+ const prefs = resolveMetricsPrefs(seedRoot)
27
+ const controller = createMetricsSidebarController(prefs, () => api.renderer.requestRender())
28
+
29
+ api.slots.register({
30
+ order: effectiveOrder,
31
+ slots: {
32
+ sidebar_content(ctx, props) {
33
+ return (
34
+ <SidebarMetrics
35
+ sessionID={props.session_id}
36
+ collector={collector}
37
+ refreshIntervalMs={config.refreshIntervalMs}
38
+ barConfig={config}
39
+ theme={ctx.theme.current}
40
+ controller={controller}
41
+ requestRender={() => api.renderer.requestRender()}
42
+ />
43
+ )
44
+ },
45
+ },
46
+ })
47
+
48
+ api.lifecycle.onDispose(() => {
49
+ collector.dispose()
50
+ })
51
+
52
+ log("opencode-metrics sidebar initialized")
53
+ }
54
+
55
+ const pluginModule: { id: string; tui: TuiPlugin } = {
56
+ id: "oc-metricboard",
57
+ tui: plugin,
58
+ }
59
+
60
+ export default pluginModule
@@ -0,0 +1,85 @@
1
+ import { getDisplayInputTokens, getDisplayOutputTokens } from "./metrics"
2
+ import type { RequestMetrics, TurnMetrics } from "./types"
3
+
4
+ export function createTurnMetrics(sessionID: string, now: number): TurnMetrics {
5
+ return {
6
+ sessionID,
7
+ turnStartTime: now,
8
+ completeTime: null,
9
+ finalizedOutputTokens: 0,
10
+ finalizedSteps: new Map(),
11
+ stickyContextTokens: 0,
12
+ stickyContextUpdatedAt: Number.NEGATIVE_INFINITY,
13
+ hasStickyContextTokens: false,
14
+ stickyCacheReadTokens: 0,
15
+ hasStickyCacheReadTokens: false,
16
+ isComplete: false,
17
+ }
18
+ }
19
+
20
+ export function ensureTurn(
21
+ turns: Map<string, TurnMetrics>,
22
+ sessionID: string,
23
+ now: number,
24
+ ): TurnMetrics {
25
+ const existing = turns.get(sessionID)
26
+ if (existing) return existing
27
+ const turn = createTurnMetrics(sessionID, now)
28
+ turns.set(sessionID, turn)
29
+ return turn
30
+ }
31
+
32
+ export function startTurn(
33
+ turns: Map<string, TurnMetrics>,
34
+ sessionID: string,
35
+ now: number,
36
+ ): TurnMetrics {
37
+ const turn = createTurnMetrics(sessionID, now)
38
+ turns.set(sessionID, turn)
39
+ return turn
40
+ }
41
+
42
+ export function retireRequestIntoTurn(
43
+ turn: TurnMetrics,
44
+ request: RequestMetrics,
45
+ updateSticky = true,
46
+ ): boolean {
47
+ if (!request.messageID) return false
48
+ const outputTokens = getDisplayOutputTokens(request)
49
+ const exact = request.hasExactTokens
50
+ const previous = turn.finalizedSteps.get(request.messageID)
51
+
52
+ if (!previous) {
53
+ turn.finalizedSteps.set(request.messageID, { outputTokens, exact })
54
+ turn.finalizedOutputTokens += outputTokens
55
+ } else if (!previous.exact && exact) {
56
+ turn.finalizedOutputTokens += outputTokens - previous.outputTokens
57
+ turn.finalizedSteps.set(request.messageID, { outputTokens, exact: true })
58
+ } else {
59
+ return false
60
+ }
61
+
62
+ if (updateSticky && exact && request.requestStartTime >= turn.stickyContextUpdatedAt) {
63
+ turn.stickyContextTokens = getDisplayInputTokens(request)
64
+ turn.stickyContextUpdatedAt = request.requestStartTime
65
+ turn.hasStickyContextTokens = true
66
+ turn.stickyCacheReadTokens = Math.max(0, request.exactCacheReadTokens)
67
+ turn.hasStickyCacheReadTokens = request.hasExactCacheReadTokens
68
+ }
69
+ return true
70
+ }
71
+
72
+ export function liveRequestOutput(turn: TurnMetrics, request: RequestMetrics | undefined): number {
73
+ if (!request?.messageID || turn.finalizedSteps.has(request.messageID)) return 0
74
+ return getDisplayOutputTokens(request)
75
+ }
76
+
77
+ export function turnInputTokens(turn: TurnMetrics, request: RequestMetrics | undefined): number {
78
+ if (turn.hasStickyContextTokens) return turn.stickyContextTokens
79
+ return request ? getDisplayInputTokens(request) : 0
80
+ }
81
+
82
+ export function completeTurn(turn: TurnMetrics, now: number): void {
83
+ turn.isComplete = true
84
+ turn.completeTime = now
85
+ }
package/src/types.ts ADDED
@@ -0,0 +1,138 @@
1
+ // src/types.ts
2
+
3
+ export interface RequestMetrics {
4
+ sessionID: string
5
+ messageID: string
6
+ modelID: string
7
+ providerID: string
8
+
9
+ // Timing
10
+ requestStartTime: number // performance.now()
11
+ firstTokenTime: number | null // Timestamp when first text delta arrives
12
+ lastDeltaTime: number | null // Last delta arrival time
13
+ completeTime: number | null // Idle timestamp
14
+
15
+ // Token counts
16
+ estimatedInputTokens: number // Estimated from user message content length / ratio
17
+ estimatedOutputTokens: number // Accumulated from delta.length / ratio
18
+ exactInputTokens: number // From AssistantMessage.tokens
19
+ exactOutputTokens: number
20
+ exactCacheReadTokens: number
21
+ exactCacheWriteTokens: number
22
+ exactReasoningTokens: number
23
+ hasExactTokens: boolean // Whether exact values have been received
24
+ hasExactCacheReadTokens: boolean
25
+ hasExactCacheWriteTokens: boolean
26
+
27
+ // State
28
+ isStreaming: boolean // Whether still streaming
29
+ isComplete: boolean // Whether request is complete (idle)
30
+ }
31
+
32
+ export interface FinalizedStepUsage {
33
+ outputTokens: number
34
+ exact: boolean
35
+ }
36
+
37
+ export interface TurnMetrics {
38
+ sessionID: string
39
+ turnStartTime: number
40
+ completeTime: number | null
41
+ finalizedOutputTokens: number
42
+ finalizedSteps: Map<string, FinalizedStepUsage>
43
+ stickyContextTokens: number
44
+ stickyContextUpdatedAt: number
45
+ hasStickyContextTokens: boolean
46
+ stickyCacheReadTokens: number
47
+ hasStickyCacheReadTokens: boolean
48
+ isComplete: boolean
49
+ }
50
+
51
+ export interface LiveSpeedSample {
52
+ at: number
53
+ cumulativeTokens: number
54
+ }
55
+
56
+ export interface LiveSpeedState {
57
+ messageID: string
58
+ cumulativeTokens: number
59
+ samples: LiveSpeedSample[]
60
+ }
61
+
62
+ export type MetricsScope = "current" | "tree"
63
+
64
+ export type CacheReadCompleteness = "exact" | "partial" | "unknown"
65
+
66
+ export interface ModelMetrics {
67
+ modelID: string
68
+ providerID: string
69
+ sessionID: string
70
+ // Number of sub-agent sessions sharing this (provider, model) — used for the
71
+ // "×N" count in the model-grouped breakdown.
72
+ sessionCount: number
73
+ inputTokens: number
74
+ outputTokens: number
75
+ cacheReadTokens: number
76
+ cacheReadCompleteness: CacheReadCompleteness
77
+ requestStartTime: number
78
+ firstTokenTime: number | null
79
+ completeTime: number | null
80
+ ttft: number | null
81
+ liveTps: number | null
82
+ isStreaming: boolean
83
+ isComplete: boolean
84
+ }
85
+
86
+ export interface MetricsAggregate {
87
+ sessionIDs: string[]
88
+ childSessionCount: number
89
+ inputTokens: number
90
+ outputTokens: number
91
+ cacheReadTokens: number
92
+ cacheReadCompleteness: CacheReadCompleteness
93
+ requestStartTime: number
94
+ firstTokenTime: number | null
95
+ completeTime: number | null
96
+ ttft: number | null
97
+ liveTps: number | null
98
+ isStreaming: boolean
99
+ isComplete: boolean
100
+ // NEW: per-model breakdown for tree scope
101
+ modelBreakdown: ModelMetrics[]
102
+ }
103
+
104
+ export interface BarConfig {
105
+ refreshIntervalMs: number
106
+ holdDurationMs: number
107
+ estimationRatio: number
108
+ enableLogging: boolean
109
+ visible: {
110
+ speed: boolean
111
+ ttft: boolean
112
+ input: boolean
113
+ output: boolean
114
+ cache: boolean
115
+ elapsed: boolean
116
+ session: boolean
117
+ model: boolean
118
+ modelBreakdown: boolean
119
+ }
120
+ }
121
+
122
+ export const DEFAULT_CONFIG: BarConfig = {
123
+ refreshIntervalMs: 200,
124
+ holdDurationMs: 0,
125
+ estimationRatio: 4.0,
126
+ enableLogging: false,
127
+ visible: {
128
+ speed: true,
129
+ ttft: true,
130
+ input: true,
131
+ output: true,
132
+ cache: false,
133
+ elapsed: true,
134
+ session: true,
135
+ model: true,
136
+ modelBreakdown: true,
137
+ },
138
+ }