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/README.md +137 -0
- package/package.json +92 -0
- package/src/assistant-progress.ts +99 -0
- package/src/badge-contrast.ts +62 -0
- package/src/collector-state.ts +36 -0
- package/src/collector.ts +615 -0
- package/src/components/SidebarMetrics.tsx +331 -0
- package/src/components/StatRow.tsx +83 -0
- package/src/config.ts +59 -0
- package/src/event-bus.ts +61 -0
- package/src/event-handlers.ts +345 -0
- package/src/event-shapes.ts +198 -0
- package/src/live-speed.ts +79 -0
- package/src/logger.ts +36 -0
- package/src/metrics.ts +243 -0
- package/src/request-state.ts +100 -0
- package/src/request-updates.ts +40 -0
- package/src/scripts/init-tui-preferences.ts +107 -0
- package/src/session-hydration.ts +278 -0
- package/src/session-timing.ts +78 -0
- package/src/session-tree.ts +43 -0
- package/src/tui-preferences.ts +203 -0
- package/src/tui-prefs-io.ts +159 -0
- package/src/tui.tsx +60 -0
- package/src/turn-state.ts +85 -0
- package/src/types.ts +138 -0
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
|
+
}
|