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.
package/src/metrics.ts ADDED
@@ -0,0 +1,243 @@
1
+ // src/metrics.ts
2
+ import type { BarConfig, CacheReadCompleteness, MetricsAggregate, RequestMetrics } from "./types"
3
+
4
+ /**
5
+ * Token estimation consistent with OpenCode internals:
6
+ * OpenCode uses CHARS_PER_TOKEN = 4
7
+ * See opencode/packages/opencode/src/util/token.ts
8
+ */
9
+ export function estimateTokens(text: string, ratio: number): number {
10
+ if (!text) return 0
11
+ const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : 4
12
+ let tokens = 0
13
+ for (const char of text) {
14
+ const isCjk = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u.test(char)
15
+ tokens += isCjk ? 0.6 : 1 / safeRatio
16
+ }
17
+ return Math.max(0, tokens)
18
+ }
19
+
20
+ /**
21
+ * Get display output token count (output + reasoning).
22
+ * Prefer exact value, fall back to estimation.
23
+ */
24
+ export function getDisplayOutputTokens(m: RequestMetrics): number {
25
+ if (m.hasExactTokens) {
26
+ return Math.max(0, m.exactOutputTokens + m.exactReasoningTokens)
27
+ }
28
+ return Math.max(0, m.estimatedOutputTokens)
29
+ }
30
+
31
+ /**
32
+ * Get display input token count (including cache).
33
+ * Exact value = input + cache.read + cache.write (total actually sent to model).
34
+ * Fall back to estimation.
35
+ */
36
+ export function getDisplayInputTokens(m: RequestMetrics): number {
37
+ if (m.hasExactTokens) {
38
+ return Math.max(0, m.exactInputTokens + m.exactCacheReadTokens + m.exactCacheWriteTokens)
39
+ }
40
+ return Math.max(0, m.estimatedInputTokens)
41
+ }
42
+
43
+ export function formatCacheRead(n: number, completeness: CacheReadCompleteness): string {
44
+ if (completeness === "unknown") return "—"
45
+ return `${formatTokens(n)}${completeness === "partial" ? "+" : ""}`
46
+ }
47
+
48
+ export function aggregateRequestMetrics(
49
+ metrics: readonly RequestMetrics[],
50
+ now: number,
51
+ ): MetricsAggregate | null {
52
+ if (metrics.length === 0) return null
53
+
54
+ let inputTokens = 0
55
+ let outputTokens = 0
56
+ let cacheReadTokens = 0
57
+ let exactCacheCount = 0
58
+ let requestStartTime = Number.POSITIVE_INFINITY
59
+ let firstTokenTime: number | null = null
60
+ let completeTime: number | null = null
61
+ let isStreaming = false
62
+ let isComplete = true
63
+
64
+ for (const m of metrics) {
65
+ inputTokens += getDisplayInputTokens(m)
66
+ outputTokens += getDisplayOutputTokens(m)
67
+ if (m.hasExactCacheReadTokens) {
68
+ cacheReadTokens += Math.max(0, m.exactCacheReadTokens)
69
+ exactCacheCount += 1
70
+ }
71
+ requestStartTime = Math.min(requestStartTime, m.requestStartTime)
72
+ if (m.firstTokenTime !== null) {
73
+ firstTokenTime = firstTokenTime === null ? m.firstTokenTime : Math.min(firstTokenTime, m.firstTokenTime)
74
+ }
75
+ if (m.completeTime !== null) {
76
+ completeTime = completeTime === null ? m.completeTime : Math.max(completeTime, m.completeTime)
77
+ }
78
+ isStreaming = isStreaming || m.isStreaming
79
+ isComplete = isComplete && m.isComplete
80
+ }
81
+
82
+ const cacheReadCompleteness: CacheReadCompleteness =
83
+ exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial"
84
+ const ttft = firstTokenTime === null ? null : Math.round(firstTokenTime - requestStartTime)
85
+ const sessionIDs = [...new Set(metrics.map((m) => m.sessionID))]
86
+
87
+ return {
88
+ sessionIDs,
89
+ childSessionCount: Math.max(0, sessionIDs.length - 1),
90
+ inputTokens,
91
+ outputTokens,
92
+ cacheReadTokens,
93
+ cacheReadCompleteness,
94
+ requestStartTime,
95
+ firstTokenTime,
96
+ completeTime: completeTime ?? (isComplete ? now : null),
97
+ ttft,
98
+ liveTps: null,
99
+ isStreaming,
100
+ isComplete,
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Calculate time to first token (milliseconds)
106
+ *
107
+ * Sanity gate: after session reopen, hydration anchors requestStartTime to the
108
+ * preceding user message's created time. If that message is hours old (long
109
+ * session) while the assistant message is recent, the raw difference would be
110
+ * minutes — not a meaningful TTFT. Values above the threshold are reported as
111
+ * null (rendered "--") rather than a misleading huge duration.
112
+ */
113
+ export const MAX_TTFT_MS = 10 * 60 * 1000
114
+
115
+ export function getTtft(m: RequestMetrics): number | null {
116
+ if (m.firstTokenTime === null) return null
117
+ const ttft = m.firstTokenTime - m.requestStartTime
118
+ if (!Number.isFinite(ttft) || ttft < 0 || ttft > MAX_TTFT_MS) return null
119
+ return Math.round(ttft)
120
+ }
121
+
122
+ /**
123
+ * Calculate average token speed (tokens/second).
124
+ * Based on cumulative streaming time (now - firstTokenTime).
125
+ */
126
+ export function getTps(m: RequestMetrics, now: number): number {
127
+ const baseTime = m.firstTokenTime ?? m.requestStartTime
128
+ const elapsedMs = now - baseTime
129
+ if (elapsedMs <= 0) return 0
130
+ const tokens = getDisplayOutputTokens(m)
131
+ return Math.round((tokens / (elapsedMs / 1000)) * 10) / 10
132
+ }
133
+
134
+ /**
135
+ * Format token count: show raw below 1000, otherwise X.XK
136
+ */
137
+ export function formatTokens(n: number): string {
138
+ if (n < 1000) return String(Math.round(n))
139
+ return (n / 1000).toFixed(1) + "K"
140
+ }
141
+
142
+ /**
143
+ * Format duration (general):
144
+ * - < 1000ms: "312ms"
145
+ * - < 60000ms: "1.5s" / "15.0s"
146
+ * - >= 60000ms: "2m5s"
147
+ */
148
+ export function formatDuration(ms: number): string {
149
+ if (ms < 1000) return `${Math.round(ms)}ms`
150
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`
151
+ const minutes = Math.floor(ms / 60000)
152
+ const seconds = Math.round((ms % 60000) / 1000)
153
+ return `${minutes}m${seconds}s`
154
+ }
155
+
156
+ /**
157
+ * Format elapsed time (always in whole seconds to avoid UI jitter):
158
+ * - < 60000ms: "0s" / "15s"
159
+ * - >= 60000ms: "2m5s"
160
+ */
161
+ export function formatElapsed(ms: number): string {
162
+ ms = Math.max(0, ms)
163
+ if (ms < 60000) return `${Math.floor(ms / 1000)}s`
164
+ const minutes = Math.floor(ms / 60000)
165
+ const seconds = Math.floor((ms % 60000) / 1000)
166
+ return `${minutes}m${seconds}s`
167
+ }
168
+
169
+ /**
170
+ * Format session-level elapsed time from a stable session start timestamp.
171
+ */
172
+ export function formatSessionElapsed(now: number, sessionStartTime: number): string {
173
+ return formatElapsed(now - sessionStartTime)
174
+ }
175
+
176
+ /**
177
+ * Format the full status bar string.
178
+ * Format: ⚡ 42.5 t/s ⏱ TTFT 312ms ↓ 1.2K in ↑ 639 out ○ cr 200 ▹ 15.0s [model]
179
+ * Control visibility of each metric via config.visible.
180
+ */
181
+ export function formatBar(m: RequestMetrics, now: number, config?: BarConfig): string {
182
+ const tps = getTps(m, now)
183
+ const ttft = getTtft(m)
184
+ const inputTokens = getDisplayInputTokens(m)
185
+ const outputTokens = getDisplayOutputTokens(m)
186
+ const cacheRead = m.hasExactCacheReadTokens ? Math.max(0, m.exactCacheReadTokens) : 0
187
+ const elapsedMs = now - m.requestStartTime
188
+ const vis = config?.visible
189
+
190
+ const parts: string[] = []
191
+
192
+ if (!vis || vis.speed) parts.push(`⚡ ${tps.toFixed(1)} t/s`)
193
+ if (!vis || vis.ttft) parts.push(`⏱ ${ttft !== null ? formatDuration(ttft) : "--"}`)
194
+ if (!vis || vis.input) parts.push(`↓ ${formatTokens(inputTokens)} in`)
195
+ if (!vis || vis.output) parts.push(`↑ ${formatTokens(outputTokens)} out`)
196
+ if ((!vis || vis.cache) && (m.hasExactTokens || m.hasExactCacheReadTokens)) {
197
+ parts.push(`○ ${formatCacheRead(cacheRead, m.hasExactCacheReadTokens ? "exact" : "unknown")}`)
198
+ }
199
+ if (!vis || vis.elapsed) parts.push(`▹ ${formatElapsed(elapsedMs)}`)
200
+
201
+ if (!vis || vis.model) {
202
+ const modelLabel = m.providerID && m.modelID
203
+ ? `${m.providerID}/${m.modelID}`
204
+ : m.modelID || "unknown"
205
+ parts.push(`[${modelLabel}]`)
206
+ }
207
+
208
+ return parts.join(" ")
209
+ }
210
+
211
+ /**
212
+ * Create initial RequestMetrics.
213
+ */
214
+ export function createFreshMetrics(
215
+ sessionID: string,
216
+ messageID: string,
217
+ modelID: string,
218
+ providerID: string,
219
+ now: number,
220
+ ): RequestMetrics {
221
+ return {
222
+ sessionID,
223
+ messageID,
224
+ modelID,
225
+ providerID,
226
+ requestStartTime: now,
227
+ firstTokenTime: null,
228
+ lastDeltaTime: null,
229
+ completeTime: null,
230
+ estimatedInputTokens: 0,
231
+ estimatedOutputTokens: 0,
232
+ exactInputTokens: 0,
233
+ exactOutputTokens: 0,
234
+ exactCacheReadTokens: 0,
235
+ exactCacheWriteTokens: 0,
236
+ exactReasoningTokens: 0,
237
+ hasExactTokens: false,
238
+ hasExactCacheReadTokens: false,
239
+ hasExactCacheWriteTokens: false,
240
+ isStreaming: false,
241
+ isComplete: false,
242
+ }
243
+ }
@@ -0,0 +1,100 @@
1
+ import type { RequestMetrics } from "./types"
2
+ import { createFreshMetrics } from "./metrics"
3
+ import { applySessionModel } from "./request-updates"
4
+ import { clearLiveSpeed, resetLiveSpeed } from "./live-speed"
5
+ import { completeTurn, ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state"
6
+ import type { LiveSpeedState, TurnMetrics } from "./types"
7
+
8
+ export interface RequestState {
9
+ readonly requests: Map<string, RequestMetrics>
10
+ readonly turns: Map<string, TurnMetrics>
11
+ readonly liveSpeeds: Map<string, LiveSpeedState>
12
+ readonly sessionModels: Map<string, { readonly modelID: string; readonly providerID: string }>
13
+ lastRequestSessionID: string | null
14
+ }
15
+
16
+ export interface RequestActions {
17
+ readonly clearHoldTimer: (sessionID: string) => void
18
+ readonly startSessionTiming: (sessionID: string, now: number) => void
19
+ readonly stopSessionTiming: (sessionID: string, now: number) => void
20
+ readonly notify: () => void
21
+ }
22
+
23
+ export interface CurrentRequestInput {
24
+ readonly state: RequestState
25
+ readonly actions: RequestActions
26
+ readonly sessionID: string
27
+ readonly messageID: string
28
+ readonly now: number
29
+ }
30
+
31
+ export function currentRequest(input: CurrentRequestInput): RequestMetrics {
32
+ const existing = input.state.requests.get(input.sessionID)
33
+ const isDifferentMessage = existing?.messageID.length ? existing.messageID !== input.messageID : false
34
+ if (!existing || isDifferentMessage) {
35
+ const priorTurn = input.state.turns.get(input.sessionID)
36
+ const startsAfterCompletedTurn = Boolean(priorTurn?.isComplete && isDifferentMessage)
37
+ const turn = startsAfterCompletedTurn
38
+ ? startTurn(input.state.turns, input.sessionID, input.now)
39
+ : ensureTurn(input.state.turns, input.sessionID, existing?.requestStartTime ?? input.now)
40
+ if (existing && isDifferentMessage && !startsAfterCompletedTurn) retireRequestIntoTurn(turn, existing)
41
+ // Keep the existing request's start time when continuing the same turn:
42
+ // opencode 1.18.x stamps step.started lazily at the first token, so a fresh
43
+ // `now` here would collapse TTFT to ~0. The user message's created time is
44
+ // the correct "request submitted" anchor.
45
+ const requestStartTime = startsAfterCompletedTurn
46
+ ? input.now
47
+ : (existing?.requestStartTime ?? input.now)
48
+ const current = createFreshMetrics(
49
+ input.sessionID,
50
+ input.messageID,
51
+ existing?.modelID ?? "",
52
+ existing?.providerID ?? "",
53
+ requestStartTime,
54
+ )
55
+ applySessionModel(current, input.state.sessionModels.get(input.sessionID))
56
+ input.state.requests.set(input.sessionID, current)
57
+ resetLiveSpeed(input.state.liveSpeeds, input.sessionID, input.messageID)
58
+ input.state.lastRequestSessionID = input.sessionID
59
+ input.actions.clearHoldTimer(input.sessionID)
60
+ return current
61
+ }
62
+ if (existing.messageID.length === 0) {
63
+ existing.messageID = input.messageID
64
+ }
65
+ input.state.lastRequestSessionID = input.sessionID
66
+ return existing
67
+ }
68
+
69
+ export interface CompleteRequestInput {
70
+ readonly state: RequestState & { readonly holdTimers: Map<string, ReturnType<typeof setTimeout>> }
71
+ readonly actions: RequestActions
72
+ readonly sessionID: string
73
+ readonly now: number
74
+ readonly holdDurationMs: number
75
+ readonly log: (msg: string) => void
76
+ }
77
+
78
+ export function completeRequest(input: CompleteRequestInput): void {
79
+ const current = input.state.requests.get(input.sessionID)
80
+ input.actions.stopSessionTiming(input.sessionID, input.now)
81
+ const turn = input.state.turns.get(input.sessionID)
82
+ if (current && turn) retireRequestIntoTurn(turn, current)
83
+ if (turn) completeTurn(turn, input.now)
84
+ clearLiveSpeed(input.state.liveSpeeds, input.sessionID)
85
+ if (!current) return
86
+ current.isStreaming = false
87
+ current.isComplete = true
88
+ current.completeTime = input.now
89
+ input.log(`request complete: session=${input.sessionID}`)
90
+ input.actions.notify()
91
+ if (input.holdDurationMs <= 0) return
92
+ input.actions.clearHoldTimer(input.sessionID)
93
+ input.state.holdTimers.set(input.sessionID, setTimeout(() => {
94
+ input.state.holdTimers.delete(input.sessionID)
95
+ if (input.state.requests.get(input.sessionID)?.sessionID === input.sessionID) {
96
+ input.state.requests.delete(input.sessionID)
97
+ input.actions.notify()
98
+ }
99
+ }, input.holdDurationMs))
100
+ }
@@ -0,0 +1,40 @@
1
+ import type { AssistantMessageEvent, AssistantTokenUpdate } from "./event-shapes"
2
+ import type { RequestMetrics } from "./types"
3
+
4
+ export interface SessionModel {
5
+ readonly modelID: string
6
+ readonly providerID: string
7
+ }
8
+
9
+ export function mergeAssistantModel(
10
+ existing: SessionModel | undefined,
11
+ message: AssistantMessageEvent,
12
+ ): SessionModel | null {
13
+ const modelID = message.modelID || existing?.modelID || ""
14
+ const providerID = message.providerID || existing?.providerID || ""
15
+ return modelID || providerID ? { modelID, providerID } : null
16
+ }
17
+
18
+ export function applySessionModel(metrics: RequestMetrics, model: SessionModel | undefined): void {
19
+ if (!model) return
20
+ metrics.modelID = model.modelID
21
+ metrics.providerID = model.providerID
22
+ }
23
+
24
+ export function hasPositiveAssistantTokens(tokens: AssistantTokenUpdate | null | undefined): tokens is AssistantTokenUpdate {
25
+ if (!tokens?.hasAny) return false
26
+ return tokens.input + tokens.output + tokens.reasoning + tokens.cacheRead + tokens.cacheWrite > 0
27
+ }
28
+
29
+ export function applyAssistantTokens(metrics: RequestMetrics, tokens: AssistantTokenUpdate): boolean {
30
+ if (!hasPositiveAssistantTokens(tokens)) return false
31
+ metrics.exactInputTokens = tokens.input
32
+ metrics.exactOutputTokens = tokens.output
33
+ metrics.exactReasoningTokens = tokens.reasoning
34
+ metrics.exactCacheReadTokens = tokens.cacheRead
35
+ metrics.exactCacheWriteTokens = tokens.cacheWrite
36
+ metrics.hasExactTokens = true
37
+ metrics.hasExactCacheReadTokens = tokens.hasCacheRead
38
+ metrics.hasExactCacheWriteTokens = tokens.hasCacheWrite
39
+ return true
40
+ }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Init/default writer for opencode-metrics TUI preferences.
4
+ *
5
+ * Inserts missing `opencode-metrics` defaults into the target prefs file
6
+ * (default: ~/.config/opencode/tui-preferences.jsonc). If `opencode-metrics`
7
+ * already exists, fills only missing nested defaults and preserves user values.
8
+ *
9
+ * If the file is malformed or `opencode-metrics` is a non-object, fails safely
10
+ * without overwriting. Uses same-directory temp file + rename for atomic write.
11
+ *
12
+ * Override the target with OPENCODE_TUI_PREFERENCES_FILE env var.
13
+ */
14
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from "node:fs"
15
+ import { dirname } from "node:path"
16
+ import { parse, stringify } from "comment-json"
17
+ import {
18
+ DEFAULT_PREFS,
19
+ PLUGIN_KEY,
20
+ } from "../tui-preferences"
21
+ import { getTuiPreferencesFile } from "../tui-prefs-io"
22
+
23
+ function isRecord(value: unknown): value is Record<string, unknown> {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value)
25
+ }
26
+
27
+ function deepFillDefaults(target: Record<string, unknown>, defaults: Record<string, unknown>): void {
28
+ for (const key of Object.keys(defaults)) {
29
+ if (!(key in target)) {
30
+ target[key] = structuredClone(defaults[key])
31
+ } else if (isRecord(defaults[key]) && isRecord(target[key])) {
32
+ deepFillDefaults(target[key] as Record<string, unknown>, defaults[key] as Record<string, unknown>)
33
+ }
34
+ }
35
+ }
36
+
37
+ export interface InitResult {
38
+ ok: boolean
39
+ file: string
40
+ reason?: string
41
+ }
42
+
43
+ export function initPreferences(fileOverride?: string): InitResult {
44
+ const file = fileOverride ?? getTuiPreferencesFile()
45
+
46
+ let text = ""
47
+ if (existsSync(file)) {
48
+ try {
49
+ text = readFileSync(file, "utf8")
50
+ } catch {
51
+ return { ok: false, file, reason: "cannot read file" }
52
+ }
53
+ }
54
+
55
+ let root: unknown = {}
56
+ if (text.trim() !== "") {
57
+ try {
58
+ root = parse(text)
59
+ } catch {
60
+ return { ok: false, file, reason: "malformed JSON/JSONC — not overwriting" }
61
+ }
62
+ }
63
+ if (!isRecord(root)) {
64
+ return { ok: false, file, reason: "root is not an object — not overwriting" }
65
+ }
66
+
67
+ const existing = root[PLUGIN_KEY]
68
+ if (existing !== undefined && !isRecord(existing)) {
69
+ return { ok: false, file, reason: `${PLUGIN_KEY} is not an object — not overwriting` }
70
+ }
71
+
72
+ if (!isRecord(root[PLUGIN_KEY])) {
73
+ root[PLUGIN_KEY] = structuredClone(DEFAULT_PREFS)
74
+ } else {
75
+ deepFillDefaults(root[PLUGIN_KEY] as Record<string, unknown>, DEFAULT_PREFS as unknown as Record<string, unknown>)
76
+ }
77
+
78
+ const output = `${stringify(root, null, 2)}\n`
79
+ const tmp = `${file}.init.${process.pid}.tmp`
80
+
81
+ try {
82
+ mkdirSync(dirname(file), { recursive: true })
83
+ writeFileSync(tmp, output, "utf8")
84
+ renameSync(tmp, file)
85
+ } catch (err) {
86
+ return { ok: false, file, reason: String(err) }
87
+ }
88
+
89
+ return { ok: true, file }
90
+ }
91
+
92
+ // CLI entry — runs only when executed directly, not when imported for tests.
93
+ const isDirectRun =
94
+ typeof process !== "undefined" &&
95
+ process.argv[1] != null &&
96
+ (process.argv[1].endsWith("init-tui-preferences.ts") ||
97
+ process.argv[1].endsWith("init-tui-preferences.js"))
98
+
99
+ if (isDirectRun) {
100
+ const result = initPreferences()
101
+ if (result.ok) {
102
+ console.log(`opencode-metrics: defaults written to ${result.file}`)
103
+ } else {
104
+ console.error(`opencode-metrics: ${result.reason} (${result.file})`)
105
+ process.exit(1)
106
+ }
107
+ }