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,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
+ }