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
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createFreshMetrics } from "./metrics"
|
|
2
|
+
import { parseAssistantMessage, parseUserMessage } from "./event-shapes"
|
|
3
|
+
import { applyAssistantTokens, hasPositiveAssistantTokens } from "./request-updates"
|
|
4
|
+
import type { RequestMetrics, TurnMetrics } from "./types"
|
|
5
|
+
import type { SessionTiming } from "./session-timing"
|
|
6
|
+
import { completeTurn, createTurnMetrics, retireRequestIntoTurn } from "./turn-state"
|
|
7
|
+
|
|
8
|
+
export interface HydrationApi {
|
|
9
|
+
readonly client: {
|
|
10
|
+
readonly session: {
|
|
11
|
+
messages(options: { readonly id: string }): Promise<unknown>
|
|
12
|
+
status(options: { readonly id: string }): Promise<unknown>
|
|
13
|
+
}
|
|
14
|
+
readonly message?: {
|
|
15
|
+
readonly parts?(options: { readonly id: string }): Promise<unknown>
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface HydrationState {
|
|
21
|
+
readonly requests: Map<string, RequestMetrics>
|
|
22
|
+
readonly turns: Map<string, TurnMetrics>
|
|
23
|
+
readonly sessionModels: Map<string, { readonly modelID: string; readonly providerID: string }>
|
|
24
|
+
readonly sessionTimings: Map<string, SessionTiming>
|
|
25
|
+
lastRequestSessionID: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
29
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function timestampOrNull(value: unknown): number | null {
|
|
33
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isHydrationApi(value: unknown): value is HydrationApi {
|
|
37
|
+
if (!isRecord(value)) return false
|
|
38
|
+
const client = value.client
|
|
39
|
+
if (!isRecord(client)) return false
|
|
40
|
+
const session = client.session
|
|
41
|
+
return isRecord(session) && typeof session.messages === "function" && typeof session.status === "function"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// SDK responses (hey-api with throwOnError=false) resolve to a fields-style
|
|
45
|
+
// result `{ data, error, response }`; some callers may hand us the bare array
|
|
46
|
+
// or object instead. Strip a single `{ data: ... }` wrapper when present.
|
|
47
|
+
function unwrapData(value: unknown): unknown {
|
|
48
|
+
if (isRecord(value) && "data" in value) return value.data
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function unwrapMessageList(response: unknown): readonly unknown[] {
|
|
53
|
+
const value = unwrapData(response)
|
|
54
|
+
return Array.isArray(value) ? value : []
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// session.status() may resolve to a single status object or to a map keyed by
|
|
58
|
+
// sessionID; prefer the entry for the requested session when present.
|
|
59
|
+
function unwrapSessionStatus(response: unknown, sessionID: string): unknown {
|
|
60
|
+
const value = unwrapData(response)
|
|
61
|
+
if (isRecord(value)) {
|
|
62
|
+
const bySessionID = value[sessionID]
|
|
63
|
+
if (typeof bySessionID === "string" || isRecord(bySessionID)) return bySessionID
|
|
64
|
+
}
|
|
65
|
+
return value
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function statusKind(value: unknown): string {
|
|
69
|
+
if (typeof value === "string") return value
|
|
70
|
+
if (!isRecord(value)) return ""
|
|
71
|
+
return typeof value.type === "string" ? value.type : ""
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function messageInfo(value: unknown): unknown {
|
|
75
|
+
if (!isRecord(value)) return value
|
|
76
|
+
return isRecord(value.info) ? value.info : value
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toPerformanceTime(wallTime: number | null, now: number): number | null {
|
|
80
|
+
if (wallTime === null) return null
|
|
81
|
+
return now - Math.max(0, Date.now() - wallTime)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function observablePartTimes(api: HydrationApi, messageID: string, now: number): Promise<{
|
|
85
|
+
readonly first: number | null
|
|
86
|
+
readonly last: number | null
|
|
87
|
+
}> {
|
|
88
|
+
const parts = api.client.message?.parts
|
|
89
|
+
if (typeof parts !== "function") return { first: null, last: null }
|
|
90
|
+
let values: readonly unknown[]
|
|
91
|
+
try {
|
|
92
|
+
values = unwrapMessageList(await parts({ id: messageID }))
|
|
93
|
+
} catch {
|
|
94
|
+
// Part timing is best-effort; unavailable/failed lookups yield null times.
|
|
95
|
+
return { first: null, last: null }
|
|
96
|
+
}
|
|
97
|
+
let firstWall: number | null = null
|
|
98
|
+
let lastWall: number | null = null
|
|
99
|
+
for (const value of values) {
|
|
100
|
+
if (!isRecord(value) || (value.type !== "text" && value.type !== "reasoning")) continue
|
|
101
|
+
const time = isRecord(value.time) ? value.time : null
|
|
102
|
+
const start = timestampOrNull(time?.start)
|
|
103
|
+
const end = timestampOrNull(time?.end)
|
|
104
|
+
if (start !== null) firstWall = firstWall === null ? start : Math.min(firstWall, start)
|
|
105
|
+
if (end !== null) lastWall = lastWall === null ? end : Math.max(lastWall, end)
|
|
106
|
+
}
|
|
107
|
+
return { first: toPerformanceTime(firstWall, now), last: toPerformanceTime(lastWall, now) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function hydrateSessionTiming(
|
|
111
|
+
rawMessages: readonly unknown[],
|
|
112
|
+
status: string,
|
|
113
|
+
now: number,
|
|
114
|
+
): SessionTiming {
|
|
115
|
+
const intervals: Array<{ start: number; end: number }> = []
|
|
116
|
+
const hasUserBoundary = rawMessages.some((raw) => parseUserMessage(messageInfo(raw)) !== null)
|
|
117
|
+
if (!hasUserBoundary) {
|
|
118
|
+
for (const raw of rawMessages) {
|
|
119
|
+
const assistant = parseAssistantMessage(messageInfo(raw))
|
|
120
|
+
const start = toPerformanceTime(assistant?.createdTime ?? null, now)
|
|
121
|
+
const end = toPerformanceTime(assistant?.completedTime ?? null, now)
|
|
122
|
+
if (start !== null && end !== null && end >= start) intervals.push({ start, end })
|
|
123
|
+
}
|
|
124
|
+
const elapsedMs = Math.round(intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0))
|
|
125
|
+
return { elapsedMs, activeSince: null, intervals }
|
|
126
|
+
}
|
|
127
|
+
let groupStartWall: number | null = null
|
|
128
|
+
let groupEndWall: number | null = null
|
|
129
|
+
|
|
130
|
+
function closeGroup(): void {
|
|
131
|
+
const start = toPerformanceTime(groupStartWall, now)
|
|
132
|
+
const end = toPerformanceTime(groupEndWall, now)
|
|
133
|
+
if (start !== null && end !== null && end >= start) intervals.push({ start, end })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
for (const raw of rawMessages) {
|
|
137
|
+
const info = messageInfo(raw)
|
|
138
|
+
const user = parseUserMessage(info)
|
|
139
|
+
if (user) {
|
|
140
|
+
if (groupStartWall !== null) closeGroup()
|
|
141
|
+
groupStartWall = user.createdTime
|
|
142
|
+
groupEndWall = null
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
const assistant = parseAssistantMessage(info)
|
|
146
|
+
if (!assistant) continue
|
|
147
|
+
groupStartWall ??= assistant.createdTime
|
|
148
|
+
if (assistant.completedTime !== null) {
|
|
149
|
+
groupEndWall = groupEndWall === null
|
|
150
|
+
? assistant.completedTime
|
|
151
|
+
: Math.max(groupEndWall, assistant.completedTime)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const activeSince = status === "idle" ? null : toPerformanceTime(groupStartWall, now)
|
|
156
|
+
if (activeSince === null && groupStartWall !== null) closeGroup()
|
|
157
|
+
const elapsedMs = Math.round(intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0))
|
|
158
|
+
return { elapsedMs, activeSince, intervals }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export interface HydrateSessionInput {
|
|
162
|
+
readonly api: HydrationApi
|
|
163
|
+
readonly state: HydrationState
|
|
164
|
+
readonly sessionID: string
|
|
165
|
+
readonly now: number
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function hydrateSession(input: HydrateSessionInput): Promise<boolean> {
|
|
169
|
+
const rawMessages = unwrapMessageList(await input.api.client.session.messages({ id: input.sessionID }))
|
|
170
|
+
const infos = rawMessages.map(messageInfo)
|
|
171
|
+
const assistants = infos
|
|
172
|
+
.map(parseAssistantMessage)
|
|
173
|
+
.filter((message): message is NonNullable<typeof message> => message !== null && hasPositiveAssistantTokens(message.tokens))
|
|
174
|
+
const assistant = assistants.at(-1)
|
|
175
|
+
if (!assistant || !hasPositiveAssistantTokens(assistant.tokens)) return false
|
|
176
|
+
|
|
177
|
+
let lastUserIndex = -1
|
|
178
|
+
for (let index = infos.length - 1; index >= 0; index -= 1) {
|
|
179
|
+
if (parseUserMessage(infos[index])) {
|
|
180
|
+
lastUserIndex = index
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const trailingAssistants = infos
|
|
185
|
+
.slice(lastUserIndex + 1)
|
|
186
|
+
.map(parseAssistantMessage)
|
|
187
|
+
.filter((message): message is NonNullable<typeof message> => message !== null && hasPositiveAssistantTokens(message.tokens))
|
|
188
|
+
const lastUser = lastUserIndex >= 0 ? parseUserMessage(infos[lastUserIndex]) : null
|
|
189
|
+
|
|
190
|
+
const existing = input.state.requests.get(input.sessionID)
|
|
191
|
+
if (existing && !existing.isComplete && existing.messageID !== assistant.messageID) return false
|
|
192
|
+
// TTFT anchor: the preceding user message's created time is the true
|
|
193
|
+
// "request submitted" instant. In opencode 1.18.x, assistant.createdTime and
|
|
194
|
+
// part.time.start are stamped in the same tick as the first text-start event,
|
|
195
|
+
// so using assistant.createdTime here yields TTFT ≈ 0.
|
|
196
|
+
const requestStartTime = toPerformanceTime(lastUser?.createdTime ?? assistant.createdTime, input.now) ?? input.now
|
|
197
|
+
const completedTime = toPerformanceTime(assistant.completedTime, input.now)
|
|
198
|
+
const sessionStatus = statusKind(unwrapSessionStatus(
|
|
199
|
+
await input.api.client.session.status({ id: input.sessionID }),
|
|
200
|
+
input.sessionID,
|
|
201
|
+
))
|
|
202
|
+
const shouldReuseExisting = existing?.messageID === assistant.messageID
|
|
203
|
+
|
|
204
|
+
const current = shouldReuseExisting ? existing : createFreshMetrics(
|
|
205
|
+
input.sessionID,
|
|
206
|
+
assistant.messageID,
|
|
207
|
+
assistant.modelID,
|
|
208
|
+
assistant.providerID,
|
|
209
|
+
requestStartTime,
|
|
210
|
+
)
|
|
211
|
+
if (!shouldReuseExisting || !current.isComplete) {
|
|
212
|
+
current.requestStartTime = requestStartTime
|
|
213
|
+
}
|
|
214
|
+
current.messageID = assistant.messageID
|
|
215
|
+
current.modelID = assistant.modelID
|
|
216
|
+
current.providerID = assistant.providerID
|
|
217
|
+
const partTimes = await observablePartTimes(input.api, assistant.messageID, input.now)
|
|
218
|
+
current.firstTokenTime = current.firstTokenTime ?? partTimes.first
|
|
219
|
+
current.lastDeltaTime = current.lastDeltaTime ?? partTimes.last ?? completedTime ?? input.now
|
|
220
|
+
current.isStreaming = !assistant.completed && sessionStatus !== "idle"
|
|
221
|
+
current.isComplete = assistant.completed || !current.isStreaming
|
|
222
|
+
if (current.isComplete && current.completeTime === null) {
|
|
223
|
+
current.completeTime = completedTime ?? input.now
|
|
224
|
+
}
|
|
225
|
+
if (!current.isComplete) {
|
|
226
|
+
current.completeTime = null
|
|
227
|
+
current.lastDeltaTime = input.now
|
|
228
|
+
}
|
|
229
|
+
applyAssistantTokens(current, assistant.tokens)
|
|
230
|
+
|
|
231
|
+
const turnStartTime = toPerformanceTime(
|
|
232
|
+
lastUser?.createdTime ?? trailingAssistants[0]?.createdTime ?? assistant.createdTime,
|
|
233
|
+
input.now,
|
|
234
|
+
) ?? requestStartTime
|
|
235
|
+
const turn = createTurnMetrics(input.sessionID, turnStartTime)
|
|
236
|
+
for (const step of trailingAssistants) {
|
|
237
|
+
if (!step.tokens || !hasPositiveAssistantTokens(step.tokens)) continue
|
|
238
|
+
const stepStart = toPerformanceTime(step.createdTime, input.now) ?? turnStartTime
|
|
239
|
+
const stepMetrics = createFreshMetrics(
|
|
240
|
+
input.sessionID,
|
|
241
|
+
step.messageID,
|
|
242
|
+
step.modelID,
|
|
243
|
+
step.providerID,
|
|
244
|
+
stepStart,
|
|
245
|
+
)
|
|
246
|
+
applyAssistantTokens(stepMetrics, step.tokens)
|
|
247
|
+
retireRequestIntoTurn(turn, stepMetrics)
|
|
248
|
+
}
|
|
249
|
+
if (sessionStatus === "idle") completeTurn(turn, completedTime ?? input.now)
|
|
250
|
+
input.state.turns.set(input.sessionID, turn)
|
|
251
|
+
|
|
252
|
+
// Write-point race guard: the checks above ran before several awaits. By
|
|
253
|
+
// now a live event handler (e.g. session.next.step.started -> currentRequest)
|
|
254
|
+
// may have replaced the requests entry with a newer in-flight request.
|
|
255
|
+
// Overwriting it with this (stale) snapshot would orphan the live request
|
|
256
|
+
// and misroute subsequent deltas. Re-check immediately before writing.
|
|
257
|
+
const liveEntry = input.state.requests.get(input.sessionID)
|
|
258
|
+
if (
|
|
259
|
+
liveEntry !== undefined &&
|
|
260
|
+
liveEntry !== current &&
|
|
261
|
+
!liveEntry.isComplete &&
|
|
262
|
+
liveEntry.messageID !== current.messageID
|
|
263
|
+
) {
|
|
264
|
+
return false
|
|
265
|
+
}
|
|
266
|
+
input.state.requests.set(input.sessionID, current)
|
|
267
|
+
input.state.lastRequestSessionID = input.sessionID
|
|
268
|
+
if (assistant.modelID || assistant.providerID) {
|
|
269
|
+
input.state.sessionModels.set(input.sessionID, { modelID: assistant.modelID, providerID: assistant.providerID })
|
|
270
|
+
}
|
|
271
|
+
if (!input.state.sessionTimings.has(input.sessionID)) {
|
|
272
|
+
input.state.sessionTimings.set(
|
|
273
|
+
input.sessionID,
|
|
274
|
+
hydrateSessionTiming(rawMessages, sessionStatus, input.now),
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
return true
|
|
278
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface SessionTiming {
|
|
2
|
+
elapsedMs: number
|
|
3
|
+
activeSince: number | null
|
|
4
|
+
intervals?: Array<{ start: number; end: number }>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function startSessionTiming(
|
|
8
|
+
timings: Map<string, SessionTiming>,
|
|
9
|
+
sessionID: string,
|
|
10
|
+
now: number,
|
|
11
|
+
): void {
|
|
12
|
+
const timing = timings.get(sessionID)
|
|
13
|
+
if (!timing) {
|
|
14
|
+
timings.set(sessionID, { elapsedMs: 0, activeSince: now, intervals: [] })
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
if (timing.activeSince === null) timing.activeSince = now
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function stopSessionTiming(
|
|
21
|
+
timings: Map<string, SessionTiming>,
|
|
22
|
+
sessionID: string,
|
|
23
|
+
now: number,
|
|
24
|
+
): void {
|
|
25
|
+
const timing = timings.get(sessionID)
|
|
26
|
+
if (!timing || timing.activeSince === null) return
|
|
27
|
+
timing.elapsedMs += Math.max(0, now - timing.activeSince)
|
|
28
|
+
timing.intervals ??= []
|
|
29
|
+
timing.intervals.push({ start: timing.activeSince, end: now })
|
|
30
|
+
timing.activeSince = null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getScopeElapsedMs(
|
|
34
|
+
timings: Map<string, SessionTiming>,
|
|
35
|
+
sessionIDs: readonly string[],
|
|
36
|
+
now: number,
|
|
37
|
+
): number {
|
|
38
|
+
const intervals: Array<{ start: number; end: number }> = []
|
|
39
|
+
let unpositionedElapsedMs = 0
|
|
40
|
+
|
|
41
|
+
for (const sessionID of sessionIDs) {
|
|
42
|
+
const timing = timings.get(sessionID)
|
|
43
|
+
if (!timing) continue
|
|
44
|
+
const positionedMs = (timing.intervals ?? []).reduce(
|
|
45
|
+
(total, interval) => total + Math.max(0, interval.end - interval.start),
|
|
46
|
+
0,
|
|
47
|
+
)
|
|
48
|
+
for (const interval of timing.intervals ?? []) intervals.push(interval)
|
|
49
|
+
if (timing.activeSince !== null) intervals.push({ start: timing.activeSince, end: now })
|
|
50
|
+
unpositionedElapsedMs += Math.max(0, timing.elapsedMs - positionedMs)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
intervals.sort((left, right) => left.start - right.start || left.end - right.end)
|
|
54
|
+
let unionMs = 0
|
|
55
|
+
let start: number | null = null
|
|
56
|
+
let end: number | null = null
|
|
57
|
+
for (const interval of intervals) {
|
|
58
|
+
if (start === null || end === null) {
|
|
59
|
+
start = interval.start
|
|
60
|
+
end = interval.end
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (interval.start <= end) {
|
|
64
|
+
end = Math.max(end, interval.end)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
unionMs += Math.max(0, end - start)
|
|
68
|
+
start = interval.start
|
|
69
|
+
end = interval.end
|
|
70
|
+
}
|
|
71
|
+
if (start !== null && end !== null) unionMs += Math.max(0, end - start)
|
|
72
|
+
return unionMs + unpositionedElapsedMs
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getSessionElapsedMs(timing: SessionTiming | undefined, now: number): number {
|
|
76
|
+
if (!timing) return 0
|
|
77
|
+
return timing.elapsedMs + (timing.activeSince === null ? 0 : Math.max(0, now - timing.activeSince))
|
|
78
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { MetricsScope } from "./types"
|
|
2
|
+
|
|
3
|
+
export interface SessionTree {
|
|
4
|
+
setParent(sessionID: string, parentID: string | null): void
|
|
5
|
+
deleteSession(sessionID: string): void
|
|
6
|
+
clear(): void
|
|
7
|
+
getScopeSessionIDs(sessionID: string, scope: MetricsScope): readonly string[]
|
|
8
|
+
getChildSessionCount(sessionID: string): number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createSessionTree(): SessionTree {
|
|
12
|
+
const parentBySession = new Map<string, string | null>()
|
|
13
|
+
|
|
14
|
+
function collectDescendants(sessionID: string, seen: Set<string>): string[] {
|
|
15
|
+
const descendants: string[] = []
|
|
16
|
+
for (const [candidateID, parentID] of parentBySession) {
|
|
17
|
+
if (parentID !== sessionID || seen.has(candidateID)) continue
|
|
18
|
+
seen.add(candidateID)
|
|
19
|
+
descendants.push(candidateID, ...collectDescendants(candidateID, seen))
|
|
20
|
+
}
|
|
21
|
+
return descendants
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
setParent(sessionID: string, parentID: string | null): void {
|
|
26
|
+
parentBySession.set(sessionID, parentID)
|
|
27
|
+
},
|
|
28
|
+
deleteSession(sessionID: string): void {
|
|
29
|
+
parentBySession.delete(sessionID)
|
|
30
|
+
},
|
|
31
|
+
clear(): void {
|
|
32
|
+
parentBySession.clear()
|
|
33
|
+
},
|
|
34
|
+
getScopeSessionIDs(sessionID: string, scope: MetricsScope): readonly string[] {
|
|
35
|
+
if (scope === "current") return [sessionID]
|
|
36
|
+
const seen = new Set<string>([sessionID])
|
|
37
|
+
return [sessionID, ...collectDescendants(sessionID, seen)]
|
|
38
|
+
},
|
|
39
|
+
getChildSessionCount(sessionID: string): number {
|
|
40
|
+
return collectDescendants(sessionID, new Set<string>([sessionID])).length
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import type { MetricsScope } from "./types"
|
|
3
|
+
import {
|
|
4
|
+
isRecord,
|
|
5
|
+
readTuiPreferencesFile,
|
|
6
|
+
queueTuiPreferenceUpdate,
|
|
7
|
+
watchTuiPreferences,
|
|
8
|
+
} from "./tui-prefs-io"
|
|
9
|
+
|
|
10
|
+
// Plugin-specific types, defaults, validation, resolution, order computation,
|
|
11
|
+
// and controller. Generic preferences file I/O lives in tui-prefs-io.ts.
|
|
12
|
+
|
|
13
|
+
export const PLUGIN_KEY = "oc-metricboard"
|
|
14
|
+
export const DEFAULT_SLOT_ORDER = 160
|
|
15
|
+
|
|
16
|
+
export interface MetricsTuiPrefs {
|
|
17
|
+
forceToTop: boolean
|
|
18
|
+
order: number
|
|
19
|
+
scope: MetricsScope
|
|
20
|
+
section: {
|
|
21
|
+
enabled: boolean
|
|
22
|
+
collapsed: boolean | null
|
|
23
|
+
rememberCollapsed: boolean
|
|
24
|
+
label: string
|
|
25
|
+
}
|
|
26
|
+
rows: {
|
|
27
|
+
speed: boolean
|
|
28
|
+
ttft: boolean
|
|
29
|
+
input: boolean
|
|
30
|
+
output: boolean
|
|
31
|
+
cache: boolean
|
|
32
|
+
elapsed: boolean
|
|
33
|
+
session: boolean
|
|
34
|
+
model: boolean
|
|
35
|
+
modelBreakdown: boolean
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_PREFS: MetricsTuiPrefs = {
|
|
40
|
+
forceToTop: false,
|
|
41
|
+
order: DEFAULT_SLOT_ORDER,
|
|
42
|
+
scope: "current",
|
|
43
|
+
section: {
|
|
44
|
+
enabled: true,
|
|
45
|
+
collapsed: null,
|
|
46
|
+
rememberCollapsed: true,
|
|
47
|
+
label: "MetricBoard",
|
|
48
|
+
},
|
|
49
|
+
rows: {
|
|
50
|
+
speed: true,
|
|
51
|
+
ttft: true,
|
|
52
|
+
input: true,
|
|
53
|
+
output: true,
|
|
54
|
+
cache: true,
|
|
55
|
+
elapsed: true,
|
|
56
|
+
session: true,
|
|
57
|
+
model: true,
|
|
58
|
+
modelBreakdown: true,
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bool(value: unknown, fallback: boolean): boolean {
|
|
63
|
+
return typeof value === "boolean" ? value : fallback
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function int(value: unknown, fallback: number, min: number, max: number): number {
|
|
67
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback
|
|
68
|
+
return Math.min(Math.max(Math.round(value), min), max)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function label(value: unknown, fallback: string, maxLength: number): string {
|
|
72
|
+
if (typeof value !== "string" || value.length === 0) return fallback
|
|
73
|
+
return value.slice(0, maxLength)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function metricsScope(value: unknown, fallback: MetricsScope): MetricsScope {
|
|
77
|
+
return value === "current" || value === "tree" ? value : fallback
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Per-key validation: every value is independently clamped/defaulted so one
|
|
81
|
+
// bad entry never poisons the rest. Never throws. A missing/non-object
|
|
82
|
+
// opencode-metrics key → full defaults clone.
|
|
83
|
+
export function resolveMetricsPrefs(root: Record<string, unknown>): MetricsTuiPrefs {
|
|
84
|
+
const entry = root[PLUGIN_KEY]
|
|
85
|
+
if (!isRecord(entry)) return structuredClone(DEFAULT_PREFS)
|
|
86
|
+
|
|
87
|
+
const d = DEFAULT_PREFS
|
|
88
|
+
const section = isRecord(entry.section) ? entry.section : {}
|
|
89
|
+
const rows = isRecord(entry.rows) ? entry.rows : {}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
forceToTop: bool(entry.forceToTop, d.forceToTop),
|
|
93
|
+
order: int(entry.order, d.order, -10000, 10000),
|
|
94
|
+
scope: metricsScope(entry.scope, d.scope),
|
|
95
|
+
section: {
|
|
96
|
+
enabled: bool(section.enabled, d.section.enabled),
|
|
97
|
+
collapsed: typeof section.collapsed === "boolean" ? section.collapsed : null,
|
|
98
|
+
rememberCollapsed: bool(section.rememberCollapsed, d.section.rememberCollapsed),
|
|
99
|
+
label: label(section.label, d.section.label, 24),
|
|
100
|
+
},
|
|
101
|
+
rows: {
|
|
102
|
+
speed: bool(rows.speed, d.rows.speed),
|
|
103
|
+
ttft: bool(rows.ttft, d.rows.ttft),
|
|
104
|
+
input: bool(rows.input, d.rows.input),
|
|
105
|
+
output: bool(rows.output, d.rows.output),
|
|
106
|
+
cache: bool(rows.cache, d.rows.cache),
|
|
107
|
+
elapsed: bool(rows.elapsed, d.rows.elapsed),
|
|
108
|
+
session: bool(rows.session, d.rows.session),
|
|
109
|
+
model: bool(rows.model, d.rows.model),
|
|
110
|
+
modelBreakdown: bool(rows.modelBreakdown, d.rows.modelBreakdown),
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const FORCE_TOP_BASE = -100000
|
|
116
|
+
|
|
117
|
+
// Shared forceToTop convention — MUST stay byte-identical across plugins or
|
|
118
|
+
// they sort inconsistently against each other. Forced plugins sort below
|
|
119
|
+
// FORCE_TOP_BASE, ordered among themselves by their top-level key's position
|
|
120
|
+
// in the file. The user-facing `order` knob clamps to -10000..10000, strictly
|
|
121
|
+
// above the forced band.
|
|
122
|
+
export function computeEffectiveOrder(
|
|
123
|
+
root: Record<string, unknown>,
|
|
124
|
+
pluginKey: string,
|
|
125
|
+
defaultOrder: number,
|
|
126
|
+
): number {
|
|
127
|
+
const entry = root[pluginKey]
|
|
128
|
+
if (!isRecord(entry)) return defaultOrder
|
|
129
|
+
if (entry.forceToTop === true) {
|
|
130
|
+
return FORCE_TOP_BASE + Object.keys(root).indexOf(pluginKey)
|
|
131
|
+
}
|
|
132
|
+
return int(entry.order, defaultOrder, -10000, 10000)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Controller ──
|
|
136
|
+
|
|
137
|
+
export interface MetricsSidebarController {
|
|
138
|
+
prefs: () => MetricsTuiPrefs
|
|
139
|
+
collapsed: () => boolean
|
|
140
|
+
toggleCollapsed: () => void
|
|
141
|
+
subscribe: (cb: () => void) => () => void
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function createMetricsSidebarController(
|
|
145
|
+
initialPrefs: MetricsTuiPrefs,
|
|
146
|
+
requestRender?: () => void,
|
|
147
|
+
): MetricsSidebarController {
|
|
148
|
+
const [prefs, setPrefs] = createSignal<MetricsTuiPrefs>(initialPrefs)
|
|
149
|
+
const seedCollapsed =
|
|
150
|
+
initialPrefs.section.rememberCollapsed && initialPrefs.section.collapsed != null
|
|
151
|
+
? initialPrefs.section.collapsed
|
|
152
|
+
: false
|
|
153
|
+
const [collapsed, setCollapsed] = createSignal(seedCollapsed)
|
|
154
|
+
let lastPersistedCollapsed: boolean | null = initialPrefs.section.collapsed
|
|
155
|
+
let lastApplied = JSON.stringify(initialPrefs)
|
|
156
|
+
|
|
157
|
+
const listeners = new Set<() => void>()
|
|
158
|
+
const notify = (): void => {
|
|
159
|
+
requestRender?.()
|
|
160
|
+
for (const cb of listeners) cb()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const disposeWatcher = watchTuiPreferences(() => {
|
|
164
|
+
void (async () => {
|
|
165
|
+
const next = resolveMetricsPrefs(await readTuiPreferencesFile())
|
|
166
|
+
const serialized = JSON.stringify(next)
|
|
167
|
+
if (serialized === lastApplied) return
|
|
168
|
+
lastApplied = serialized
|
|
169
|
+
setPrefs(next)
|
|
170
|
+
if (
|
|
171
|
+
next.section.rememberCollapsed &&
|
|
172
|
+
next.section.collapsed != null &&
|
|
173
|
+
next.section.collapsed !== lastPersistedCollapsed
|
|
174
|
+
) {
|
|
175
|
+
lastPersistedCollapsed = next.section.collapsed
|
|
176
|
+
setCollapsed(next.section.collapsed)
|
|
177
|
+
}
|
|
178
|
+
notify()
|
|
179
|
+
})()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// Intentionally not calling disposeWatcher here — the watcher lives for
|
|
183
|
+
// the plugin/process lifetime, matching Magic Context's convention.
|
|
184
|
+
void disposeWatcher
|
|
185
|
+
|
|
186
|
+
function toggleCollapsed() {
|
|
187
|
+
const next = !collapsed()
|
|
188
|
+
setCollapsed(next)
|
|
189
|
+
notify()
|
|
190
|
+
if (prefs().section.rememberCollapsed) {
|
|
191
|
+
void queueTuiPreferenceUpdate(PLUGIN_KEY, ["section", "collapsed"], next).then(() => {
|
|
192
|
+
lastPersistedCollapsed = next
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function subscribe(cb: () => void): () => void {
|
|
198
|
+
listeners.add(cb)
|
|
199
|
+
return () => listeners.delete(cb)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { prefs, collapsed, toggleCollapsed, subscribe }
|
|
203
|
+
}
|