@jack-yang/opencode-tps-meter 1.2.0 → 1.3.0

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/tui.tsx CHANGED
@@ -1,96 +1,74 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { RGBA } from "@opentui/core"
2
+ import { RGBA, StyledText } from "@opentui/core"
3
3
  import type { TextRenderable } from "@opentui/core"
4
4
  import type { TuiPlugin, TuiPluginModule, TuiThemeCurrent } from "@opencode-ai/plugin/tui"
5
- import type { PluginOptions } from "@opencode-ai/plugin"
6
5
  import { onCleanup } from "solid-js"
7
6
 
8
- type DeltaSample = { at: number; rawTokens: number }
7
+ // ==== BEGIN PURE MEASURE (keep in sync with measure.ts — test/sync.test.ts enforces) ====
8
+ const WINDOW_MS = 5_000
9
+ const STALE_MS = 1_500
10
+ const THINKING_STALE_MS = 10_000
11
+ const MIN_DURATION_MS = 1_000
12
+ const MAX_CALIBRATION_RATIOS = 10
13
+ const MAX_TRACKED_MESSAGES = 32
14
+ const IDLE_TICK_MS = 250
15
+ const HUE_PERIOD = 60
9
16
 
10
- type SessionStats = {
11
- totalOutputTokens: number
12
- totalDurationMs: number
13
- calibrationRatios: number[]
17
+ type DeltaKind = "text" | "reasoning" | "tool"
18
+ type DeltaSample = { at: number; rawTokens: number }
19
+ type MessageTrack = {
20
+ createdAt?: number
21
+ firstAt?: number
22
+ lastAt?: number
23
+ estimated: number
24
+ sawReasoning: boolean
25
+ sawTool: boolean
26
+ toolPartSeen: boolean
14
27
  }
15
-
28
+ type SessionStats = { totalOutputTokens: number; totalDurationMs: number; calibrationRatios: number[] }
16
29
  type TrackerState = {
17
30
  useV2Events: boolean
18
31
  samplesBySession: Record<string, DeltaSample[]>
19
- messageCreatedAt: Record<string, number>
20
- firstDeltaByMessage: Record<string, number>
21
- lastDeltaByMessage: Record<string, number>
22
- estimatedTokensByMessage: Record<string, number>
32
+ messages: Record<string, MessageTrack>
23
33
  liveTtftBySession: Record<string, number>
24
34
  activeMessageBySession: Record<string, string | undefined>
25
35
  statsBySession: Record<string, SessionStats>
26
36
  thinkingBySession: Record<string, boolean>
37
+ lastDeltaAtBySession: Record<string, number>
27
38
  }
28
39
 
29
- type Listener = () => void
30
-
31
- type TpsTiers = { slow: number; normal: number; fast: number }
32
- type TtftTiers = { fast: number; ok: number }
33
- type SpinnerCfg = { colors: RGBA[]; intervalMs: number; enabled: boolean; cells: number }
34
- type ResolvedConfig = { spinner: SpinnerCfg; tps: TpsTiers; ttft: TtftTiers }
35
-
36
- const THEME_PRESETS: Record<string, string[]> = {
37
- tech: ["#00e5ff", "#2979ff", "#651fff"],
38
- red: ["#ff1744", "#ff5252", "#ff8a80"],
39
- }
40
-
41
- function resolveConfig(options: PluginOptions | undefined): ResolvedConfig {
42
- const o = (options ?? {}) as {
43
- spinner?: { theme?: string; colors?: string[]; intervalMs?: number; enabled?: boolean; cells?: number }
44
- tiers?: { tps?: Partial<TpsTiers>; ttft?: Partial<TtftTiers> }
45
- }
46
- const sp = o.spinner ?? {}
47
- let colors: RGBA[]
48
- if (Array.isArray(sp.colors) && sp.colors.length > 0) {
49
- colors = sp.colors.map((c) => RGBA.fromHex(c))
50
- } else {
51
- const preset = THEME_PRESETS[sp.theme ?? "tech"] ?? THEME_PRESETS.tech
52
- colors = preset.map((c) => RGBA.fromHex(c))
53
- }
54
- const cells = typeof sp.cells === "number" && sp.cells >= 2 ? Math.floor(sp.cells) : 6
55
- const spinner: SpinnerCfg = {
56
- colors,
57
- intervalMs: typeof sp.intervalMs === "number" && sp.intervalMs > 0 ? sp.intervalMs : 100,
58
- enabled: sp.enabled !== false && (sp.theme ?? "tech") !== "none",
59
- cells,
60
- }
61
- const tiers = o.tiers ?? {}
62
- const tp = tiers.tps ?? {}
63
- const tt = tiers.ttft ?? {}
40
+ function createTracker(): TrackerState {
64
41
  return {
65
- spinner,
66
- tps: { slow: tp.slow ?? 20, normal: tp.normal ?? 50, fast: tp.fast ?? 100 },
67
- ttft: { fast: tt.fast ?? 10000, ok: tt.ok ?? 20000 },
42
+ useV2Events: false,
43
+ samplesBySession: {},
44
+ messages: {},
45
+ liveTtftBySession: {},
46
+ activeMessageBySession: {},
47
+ statsBySession: {},
48
+ thinkingBySession: {},
49
+ lastDeltaAtBySession: {},
68
50
  }
69
51
  }
70
52
 
71
- function lerp(a: number, b: number, t: number): number {
72
- return a + (b - a) * t
73
- }
74
- function lerpColor(a: RGBA, b: RGBA, t: number): RGBA {
75
- return RGBA.fromValues(lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t), 1)
76
- }
77
- function gradientColor(colors: RGBA[], t: number): RGBA {
78
- if (colors.length === 0) return RGBA.fromHex("#888888")
79
- if (colors.length === 1) return colors[0]
80
- const c = Math.max(0, Math.min(1, t))
81
- const seg = c * (colors.length - 1)
82
- const i = Math.min(Math.floor(seg), colors.length - 2)
83
- const f = seg - i
84
- return lerpColor(colors[i], colors[i + 1], f)
53
+ function utf8ByteLength(s: string): number {
54
+ let bytes = 0
55
+ for (let i = 0; i < s.length; i++) {
56
+ const c = s.charCodeAt(i)
57
+ if (c < 0x80) bytes += 1
58
+ else if (c < 0x800) bytes += 2
59
+ else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
60
+ const n = s.charCodeAt(i + 1)
61
+ if (n >= 0xdc00 && n <= 0xdfff) {
62
+ bytes += 4
63
+ i++
64
+ } else bytes += 3
65
+ } else bytes += 3
66
+ }
67
+ return bytes
85
68
  }
86
69
 
87
- const WINDOW_MS = 5_000
88
- const STALE_MS = 1_500
89
- const MIN_DURATION_MS = 1_000
90
- const MAX_CALIBRATION_RATIOS = 10
91
-
92
70
  function estimateTokens(delta: string): number {
93
- return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 4))
71
+ return Math.max(1, Math.ceil(utf8ByteLength(delta) / 4))
94
72
  }
95
73
 
96
74
  function formatTps(value: number): string | undefined {
@@ -106,71 +84,24 @@ function formatTtft(valueMs: number): string | undefined {
106
84
  }
107
85
 
108
86
  type SpeedTier = "slow" | "normal" | "fast" | "faster"
109
- type Palette = { muted: RGBA; error: RGBA; warning: RGBA; success: RGBA; accent: RGBA }
110
-
111
- type V2Event = { type: string; created: number; data: Record<string, any> }
112
- type V2Theme = {
113
- text: {
114
- muted: RGBA
115
- feedback: Record<"error" | "warning" | "success" | "info", { base: RGBA; muted: RGBA }>
116
- }
117
- hue: Record<string, Partial<Record<number, RGBA>>>
118
- }
119
- type V2Context = {
120
- options: Record<string, unknown> | undefined
121
- renderer: { requestRender(): void }
122
- theme: V2Theme
123
- data: { on(type: string, handler: (event: V2Event) => void): () => void }
124
- ui: {
125
- slot(claim: { append: string; render: (input: { sessionID?: string }) => unknown }): () => void
126
- router: { current(): { type: string; sessionID?: string } }
127
- }
128
- }
129
-
130
- function v1Palette(t: TuiThemeCurrent): Palette {
131
- return { muted: t.textMuted, error: t.error, warning: t.warning, success: t.success, accent: t.accent }
132
- }
133
- function v2Palette(t: V2Theme): Palette {
134
- return {
135
- muted: t.text.muted,
136
- error: t.text.feedback.error.base,
137
- warning: t.text.feedback.warning.base,
138
- success: t.text.feedback.success.base,
139
- accent: t.hue.accent?.[500] ?? t.text.feedback.info.base,
140
- }
141
- }
87
+ type TtftTier = "fast" | "ok" | "slow"
88
+ type TpsTiers = { slow: number; normal: number; fast: number }
89
+ type TtftTiers = { fast: number; ok: number }
90
+ const DEFAULT_TPS_TIERS: TpsTiers = { slow: 20, normal: 50, fast: 100 }
91
+ const DEFAULT_TTFT_TIERS: TtftTiers = { fast: 10_000, ok: 20_000 }
142
92
 
143
- function speedTier(tps: number, c: TpsTiers): SpeedTier {
144
- if (tps < c.slow) return "slow"
145
- if (tps < c.normal) return "normal"
146
- if (tps < c.fast) return "fast"
93
+ function speedTier(tps: number, tiers: TpsTiers = DEFAULT_TPS_TIERS): SpeedTier {
94
+ if (tps < tiers.slow) return "slow"
95
+ if (tps < tiers.normal) return "normal"
96
+ if (tps < tiers.fast) return "fast"
147
97
  return "faster"
148
98
  }
149
- function tierColor(tier: SpeedTier, p: Palette): RGBA {
150
- switch (tier) {
151
- case "slow": return p.error
152
- case "normal": return p.warning
153
- case "fast": return p.success
154
- case "faster": return p.accent
155
- }
156
- }
157
- function tpsColor(tps: number, c: TpsTiers, p: Palette): RGBA {
158
- return tierColor(speedTier(tps, c), p)
159
- }
160
99
 
161
- type TtftTier = "fast" | "ok" | "slow"
162
- function ttftTier(ms: number, c: TtftTiers): TtftTier {
163
- if (ms < c.fast) return "fast"
164
- if (ms < c.ok) return "ok"
100
+ function ttftTier(ms: number, tiers: TtftTiers = DEFAULT_TTFT_TIERS): TtftTier {
101
+ if (ms < tiers.fast) return "fast"
102
+ if (ms < tiers.ok) return "ok"
165
103
  return "slow"
166
104
  }
167
- function ttftColor(ms: number, c: TtftTiers, p: Palette): RGBA {
168
- switch (ttftTier(ms, c)) {
169
- case "fast": return p.success
170
- case "ok": return p.warning
171
- case "slow": return p.error
172
- }
173
- }
174
105
 
175
106
  function median(values: number[]): number {
176
107
  if (values.length === 0) return 1.0
@@ -179,6 +110,7 @@ function median(values: number[]): number {
179
110
  if (sorted.length % 2 !== 0) return sorted[mid]
180
111
  return Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 1e10) / 1e10
181
112
  }
113
+
182
114
  function calibrationFactor(ratios: number[]): number {
183
115
  return ratios.length === 0 ? 1.0 : median(ratios)
184
116
  }
@@ -196,297 +128,591 @@ function calculateLiveTps(samples: DeltaSample[], now: number, calibration: numb
196
128
  return (totalRaw * calibration) / (durationMs / 1000)
197
129
  }
198
130
 
199
- function recordDelta(tracker: TrackerState, sessionID: string, messageID: string, at: number, delta: string) {
200
- const rawTokens = estimateTokens(delta)
201
- const cutoff = at - WINDOW_MS
202
- const existing = tracker.samplesBySession[sessionID] ?? []
203
- tracker.samplesBySession[sessionID] = [...existing.filter((s) => s.at >= cutoff), { at, rawTokens }]
204
- if (tracker.firstDeltaByMessage[messageID] === undefined) {
205
- tracker.firstDeltaByMessage[messageID] = at
206
- const created = tracker.messageCreatedAt[messageID]
207
- if (created !== undefined) {
208
- tracker.liveTtftBySession[sessionID] = Math.max(0, at - created)
131
+ function liveTps(tracker: TrackerState, sessionID: string, now: number): number | undefined {
132
+ const samples = tracker.samplesBySession[sessionID]
133
+ if (!samples) return undefined
134
+ const calibration = calibrationFactor(tracker.statsBySession[sessionID]?.calibrationRatios ?? [])
135
+ return calculateLiveTps(samples, now, calibration)
136
+ }
137
+
138
+ function avgTps(tracker: TrackerState, sessionID: string): number | undefined {
139
+ const stats = tracker.statsBySession[sessionID]
140
+ if (!stats || stats.totalDurationMs <= 0) return undefined
141
+ return stats.totalOutputTokens / (stats.totalDurationMs / 1000)
142
+ }
143
+
144
+ function trackMessage(tracker: TrackerState, messageID: string): MessageTrack {
145
+ let track = tracker.messages[messageID]
146
+ if (track) return track
147
+ track = { estimated: 0, sawReasoning: false, sawTool: false, toolPartSeen: false }
148
+ tracker.messages[messageID] = track
149
+ const ids = Object.keys(tracker.messages)
150
+ for (let i = 0; i < ids.length - MAX_TRACKED_MESSAGES; i++) delete tracker.messages[ids[i]]
151
+ return track
152
+ }
153
+
154
+ /** Returns true if this is the first time the message is seen (i.e. a new step started). */
155
+ function recordMessageStart(tracker: TrackerState, messageID: string, createdAt: number | undefined): boolean {
156
+ const isNew = tracker.messages[messageID] === undefined
157
+ const track = trackMessage(tracker, messageID)
158
+ if (track.createdAt === undefined && typeof createdAt === "number") track.createdAt = createdAt
159
+ return isNew
160
+ }
161
+
162
+ function setThinking(tracker: TrackerState, sessionID: string, value: boolean): void {
163
+ if (value) tracker.thinkingBySession[sessionID] = true
164
+ else delete tracker.thinkingBySession[sessionID]
165
+ }
166
+
167
+ function anyThinking(tracker: TrackerState): boolean {
168
+ for (const id in tracker.thinkingBySession) if (tracker.thinkingBySession[id]) return true
169
+ return false
170
+ }
171
+
172
+ type DeltaInput = {
173
+ sessionID: string
174
+ messageID: string
175
+ kind: DeltaKind
176
+ delta: string
177
+ /** Local receive time (Date.now()) — used for windows/staleness, immune to server clock skew. */
178
+ at: number
179
+ /** Server timestamp of the delta — used only for TTFT (createdAt is server time too). */
180
+ serverAt?: number
181
+ }
182
+
183
+ /** Returns true when this delta was the first output token of its message (TTFT just became known). */
184
+ function recordDelta(tracker: TrackerState, d: DeltaInput): boolean {
185
+ const rawTokens = estimateTokens(d.delta)
186
+ let samples = tracker.samplesBySession[d.sessionID]
187
+ if (!samples) samples = tracker.samplesBySession[d.sessionID] = []
188
+ samples.push({ at: d.at, rawTokens })
189
+ const cutoff = d.at - WINDOW_MS
190
+ let drop = 0
191
+ while (drop < samples.length && samples[drop].at < cutoff) drop++
192
+ if (drop > 0) samples.splice(0, drop)
193
+
194
+ const track = trackMessage(tracker, d.messageID)
195
+ let first = false
196
+ if (track.firstAt === undefined) {
197
+ track.firstAt = d.at
198
+ first = true
199
+ if (track.createdAt !== undefined) {
200
+ tracker.liveTtftBySession[d.sessionID] = Math.max(0, (d.serverAt ?? d.at) - track.createdAt)
209
201
  }
210
202
  }
211
- tracker.lastDeltaByMessage[messageID] = at
212
- tracker.estimatedTokensByMessage[messageID] = (tracker.estimatedTokensByMessage[messageID] ?? 0) + rawTokens
213
- tracker.activeMessageBySession[sessionID] = messageID
203
+ track.lastAt = d.at
204
+ track.estimated += rawTokens
205
+ if (d.kind === "reasoning") track.sawReasoning = true
206
+ if (d.kind === "tool") track.sawTool = true
207
+
208
+ tracker.activeMessageBySession[d.sessionID] = d.messageID
209
+ tracker.lastDeltaAtBySession[d.sessionID] = d.at
210
+ setThinking(tracker, d.sessionID, d.kind === "reasoning")
211
+ return first
214
212
  }
215
213
 
216
- const DOT_GLYPHS = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⠒", "⠑", "⠡", "⠢", "⠤", "⠨", "⠰", "⠸", "⠘", "⠔", "⠖", "⠦"]
214
+ /** Legacy path marker: a tool part of this message started running (no tool input deltas are emitted there). */
215
+ function recordToolBoundary(tracker: TrackerState, messageID: string): void {
216
+ trackMessage(tracker, messageID).toolPartSeen = true
217
+ }
217
218
 
218
- type MeterCore = {
219
- tracker: TrackerState
220
- bump: () => void
221
- subscribe: (listener: Listener) => () => void
222
- spinnerCells: () => { glyph: string; color: RGBA }[]
223
- setThinking: (sessionID: string, value: boolean) => void
224
- clearSessionLive: (sessionID: string) => void
225
- dispose: () => void
219
+ function clearSessionLive(tracker: TrackerState, sessionID: string): void {
220
+ delete tracker.samplesBySession[sessionID]
221
+ setThinking(tracker, sessionID, false)
226
222
  }
227
223
 
228
- function createMeterCore(config: ResolvedConfig, muted: RGBA): MeterCore {
229
- const tracker: TrackerState = {
230
- useV2Events: false,
231
- samplesBySession: {},
232
- messageCreatedAt: {},
233
- firstDeltaByMessage: {},
234
- lastDeltaByMessage: {},
235
- estimatedTokensByMessage: {},
236
- liveTtftBySession: {},
237
- activeMessageBySession: {},
238
- statsBySession: {},
239
- thinkingBySession: {},
224
+ function discardMessage(tracker: TrackerState, messageID: string): void {
225
+ delete tracker.messages[messageID]
226
+ for (const sid in tracker.activeMessageBySession) {
227
+ if (tracker.activeMessageBySession[sid] === messageID) delete tracker.activeMessageBySession[sid]
240
228
  }
241
- const listeners = new Set<Listener>()
242
- const bump = () => { for (const l of listeners) l() }
243
- const subscribe = (listener: Listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }
244
-
245
- const N = config.spinner.cells
246
- const randomDot = () => DOT_GLYPHS[Math.floor(Math.random() * DOT_GLYPHS.length)]
247
- const spawnGlyph = () => (Math.random() < 0.18 ? " " : randomDot())
248
- let stream: string[] = []
249
- let hueFrame = 0
250
- let spinnerTimer: ReturnType<typeof setInterval> | undefined
251
-
252
- const initStream = () => {
253
- stream = Array.from({ length: N }, () => spawnGlyph())
254
- hueFrame = 0
229
+ }
230
+
231
+ type CompletedMessage = {
232
+ id: string
233
+ sessionID: string
234
+ tokens: { output: number; reasoning: number }
235
+ finish?: string
236
+ }
237
+
238
+ function recordMessageCompleted(tracker: TrackerState, info: CompletedMessage): void {
239
+ const sessionID = info.sessionID
240
+ const track = tracker.messages[info.id]
241
+ if (track) {
242
+ let stats = tracker.statsBySession[sessionID]
243
+ if (!stats) stats = tracker.statsBySession[sessionID] = { totalOutputTokens: 0, totalDurationMs: 0, calibrationRatios: [] }
244
+ const generationMs = track.firstAt !== undefined && track.lastAt !== undefined
245
+ ? Math.max(track.lastAt - track.firstAt, MIN_DURATION_MS) : 0
246
+ const output = Number(info.tokens?.output) || 0
247
+ const reasoning = Number(info.tokens?.reasoning) || 0
248
+ const actual = output + (track.sawReasoning ? reasoning : 0)
249
+ const excludeForTools = !track.sawTool && (info.finish === "tool-calls" || track.toolPartSeen)
250
+ if (!excludeForTools && actual > 0 && generationMs > 0) {
251
+ stats.totalOutputTokens += actual
252
+ stats.totalDurationMs += generationMs
253
+ if (track.estimated > 0) {
254
+ stats.calibrationRatios.push(Math.min(Math.max(actual / track.estimated, 0.3), 3.0))
255
+ if (stats.calibrationRatios.length > MAX_CALIBRATION_RATIOS) {
256
+ stats.calibrationRatios.splice(0, stats.calibrationRatios.length - MAX_CALIBRATION_RATIOS)
257
+ }
258
+ }
259
+ }
260
+ delete tracker.messages[info.id]
255
261
  }
256
- const tickStream = () => {
257
- stream.unshift(spawnGlyph())
258
- if (stream.length > N) stream.pop()
259
- hueFrame++
262
+ const active = tracker.activeMessageBySession[sessionID]
263
+ if (active === undefined || active === info.id) {
264
+ delete tracker.activeMessageBySession[sessionID]
265
+ clearSessionLive(tracker, sessionID)
260
266
  }
261
- initStream()
262
-
263
- const spinnerCells = () => {
264
- const bright = gradientColor(config.spinner.colors, (hueFrame % 60) / 60)
265
- const out: { glyph: string; color: RGBA }[] = []
266
- for (let i = 0; i < N; i++) {
267
- const g = stream[i] ?? " "
268
- out.push({ glyph: g, color: g === " " ? muted : lerpColor(bright, muted, i / (N - 1)) })
267
+ }
268
+
269
+ /** Trims stale samples and stale thinking flags. Returns whether anything is still live. */
270
+ function prune(tracker: TrackerState, now: number): boolean {
271
+ const cutoff = now - WINDOW_MS
272
+ let live = false
273
+ for (const sid in tracker.samplesBySession) {
274
+ const samples = tracker.samplesBySession[sid]
275
+ let drop = 0
276
+ while (drop < samples.length && samples[drop].at < cutoff) drop++
277
+ if (drop === samples.length) delete tracker.samplesBySession[sid]
278
+ else {
279
+ if (drop > 0) samples.splice(0, drop)
280
+ live = true
269
281
  }
270
- return out
271
282
  }
272
-
273
- const anyThinking = () => {
274
- for (const v of Object.values(tracker.thinkingBySession)) if (v) return true
275
- return false
283
+ for (const sid in tracker.thinkingBySession) {
284
+ const last = tracker.lastDeltaAtBySession[sid]
285
+ if (last === undefined || now - last > THINKING_STALE_MS) setThinking(tracker, sid, false)
286
+ else live = true
276
287
  }
277
- const syncSpinnerTimer = () => {
278
- if (config.spinner.enabled && anyThinking() && !spinnerTimer) {
279
- spinnerTimer = setInterval(() => {
280
- tickStream()
281
- bump()
282
- }, config.spinner.intervalMs)
283
- } else if ((!anyThinking() || !config.spinner.enabled) && spinnerTimer) {
284
- clearInterval(spinnerTimer)
285
- spinnerTimer = undefined
286
- }
288
+ return live
289
+ }
290
+
291
+ type SpinnerOptions = { colors: string[]; intervalMs: number; enabled: boolean; cells: number }
292
+ type ResolvedOptions = { spinner: SpinnerOptions; tps: TpsTiers; ttft: TtftTiers; slot: string }
293
+
294
+ const THEME_PRESETS: Record<string, string[]> = {
295
+ tech: ["#00e5ff", "#2979ff", "#651fff"],
296
+ red: ["#ff1744", "#ff5252", "#ff8a80"],
297
+ }
298
+ const HEX_COLOR = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i
299
+
300
+ function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
301
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback
302
+ return Math.min(max, Math.max(min, value))
303
+ }
304
+
305
+ function positive(value: unknown, fallback: number): number {
306
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback
307
+ }
308
+
309
+ function resolveOptions(raw: unknown): ResolvedOptions {
310
+ const o = (raw && typeof raw === "object" ? raw : {}) as {
311
+ spinner?: { theme?: unknown; colors?: unknown; intervalMs?: unknown; enabled?: unknown; cells?: unknown }
312
+ tiers?: { tps?: Partial<Record<keyof TpsTiers, unknown>>; ttft?: Partial<Record<keyof TtftTiers, unknown>> }
313
+ slot?: unknown
287
314
  }
288
- const setThinking = (sessionID: string, value: boolean) => {
289
- if (tracker.thinkingBySession[sessionID] === value) return
290
- tracker.thinkingBySession[sessionID] = value
291
- if (value) initStream()
292
- syncSpinnerTimer()
315
+ const sp = o.spinner && typeof o.spinner === "object" ? o.spinner : {}
316
+ const theme = typeof sp.theme === "string" && (sp.theme === "none" || THEME_PRESETS[sp.theme]) ? sp.theme : "tech"
317
+ const custom = Array.isArray(sp.colors) ? sp.colors.filter((c): c is string => typeof c === "string" && HEX_COLOR.test(c)) : []
318
+ const colors = custom.length > 0 ? custom : THEME_PRESETS[theme] ?? THEME_PRESETS.tech
319
+ const spinner: SpinnerOptions = {
320
+ colors,
321
+ intervalMs: clampNumber(sp.intervalMs, 40, 1000, 100),
322
+ enabled: sp.enabled !== false && theme !== "none",
323
+ cells: Math.floor(clampNumber(sp.cells, 2, 16, 6)),
293
324
  }
294
- const clearSessionLive = (sessionID: string) => {
295
- delete tracker.samplesBySession[sessionID]
296
- setThinking(sessionID, false)
325
+ const tiers = o.tiers && typeof o.tiers === "object" ? o.tiers : {}
326
+ const tp = tiers.tps ?? {}
327
+ const tt = tiers.ttft ?? {}
328
+ let tps: TpsTiers = {
329
+ slow: positive(tp.slow, DEFAULT_TPS_TIERS.slow),
330
+ normal: positive(tp.normal, DEFAULT_TPS_TIERS.normal),
331
+ fast: positive(tp.fast, DEFAULT_TPS_TIERS.fast),
297
332
  }
333
+ if (!(tps.slow < tps.normal && tps.normal < tps.fast)) tps = { ...DEFAULT_TPS_TIERS }
334
+ let ttft: TtftTiers = { fast: positive(tt.fast, DEFAULT_TTFT_TIERS.fast), ok: positive(tt.ok, DEFAULT_TTFT_TIERS.ok) }
335
+ if (!(ttft.fast < ttft.ok)) ttft = { ...DEFAULT_TTFT_TIERS }
336
+ const slot = typeof o.slot === "string" && o.slot.trim() ? o.slot : "prompt.footer.status"
337
+ return { spinner, tps, ttft, slot }
338
+ }
298
339
 
299
- return {
300
- tracker,
301
- bump,
302
- subscribe,
303
- spinnerCells,
304
- setThinking,
305
- clearSessionLive,
306
- dispose: () => {
307
- if (spinnerTimer) clearInterval(spinnerTimer)
340
+ const DOT_GLYPHS = ["⠁", "⠂", "⠄", "⠈", "⠐", "⠠", "⠒", "⠑", "⠡", "⠢", "⠤", "⠨", "⠰", "⠸", "⠘", "⠔", "⠖", "⠦"]
341
+
342
+ type Spinner = { cells: number; glyphs: string[]; frame: number; reset(): void; step(): void }
343
+
344
+ function createSpinner(cells: number, random: () => number = Math.random): Spinner {
345
+ const spawn = () => (random() < 0.18 ? " " : DOT_GLYPHS[Math.floor(random() * DOT_GLYPHS.length)])
346
+ const s: Spinner = {
347
+ cells,
348
+ glyphs: [],
349
+ frame: 0,
350
+ reset() {
351
+ s.glyphs = Array.from({ length: cells }, spawn)
352
+ s.frame = 0
353
+ },
354
+ step() {
355
+ s.glyphs.unshift(spawn())
356
+ if (s.glyphs.length > cells) s.glyphs.pop()
357
+ s.frame++
308
358
  },
309
359
  }
360
+ s.reset()
361
+ return s
310
362
  }
311
363
 
312
- function recordMessageCompleted(core: MeterCore, info: {
313
- id: string
314
- sessionID: string
315
- tokens: { output: number; reasoning: number }
316
- }) {
317
- const tracker = core.tracker
318
- const sessionID = info.sessionID
319
- if (!tracker.statsBySession[sessionID]) {
320
- tracker.statsBySession[sessionID] = { totalOutputTokens: 0, totalDurationMs: 0, calibrationRatios: [] }
364
+ type PaletteKey = "muted" | "error" | "warning" | "success" | "accent"
365
+ /** A color reference resolved to a host color by the adapter. `cell` = particle index, `hue` = gradient phase (0..HUE_PERIOD-1). */
366
+ type ColorToken = PaletteKey | { cell: number; hue: number }
367
+ type Segment = { text: string; color: ColorToken }
368
+
369
+ const IDLE_TEXT = "TPS -- | AVG -- | TTFT --"
370
+
371
+ function speedToken(tier: SpeedTier): PaletteKey {
372
+ return tier === "slow" ? "error" : tier === "normal" ? "warning" : tier === "fast" ? "success" : "accent"
373
+ }
374
+
375
+ function ttftToken(tier: TtftTier): PaletteKey {
376
+ return tier === "fast" ? "success" : tier === "ok" ? "warning" : "error"
377
+ }
378
+
379
+ function colorKey(color: ColorToken): string {
380
+ return typeof color === "string" ? color : `c${color.cell}.${color.hue}`
381
+ }
382
+
383
+ function segmentsKey(segments: Segment[]): string {
384
+ return segments.map((s) => s.text + "\u0000" + colorKey(s.color)).join("\u0001")
385
+ }
386
+
387
+ function buildSegments(
388
+ tracker: TrackerState,
389
+ sessionID: string | undefined,
390
+ now: number,
391
+ options: { spinner: { enabled: boolean }; tps: TpsTiers; ttft: TtftTiers },
392
+ spinner?: Spinner,
393
+ ): Segment[] {
394
+ const out: Segment[] = []
395
+ const push = (text: string, color: ColorToken) => {
396
+ const prev = out[out.length - 1]
397
+ if (prev && typeof color === "string" && prev.color === color) prev.text += text
398
+ else out.push({ text, color })
321
399
  }
322
- const stats = tracker.statsBySession[sessionID]!
323
-
324
- const actualTokens = info.tokens.output + info.tokens.reasoning
325
- const firstDelta = tracker.firstDeltaByMessage[info.id]
326
- const lastDelta = tracker.lastDeltaByMessage[info.id]
327
- const generationMs = firstDelta !== undefined && lastDelta !== undefined
328
- ? Math.max(lastDelta - firstDelta, MIN_DURATION_MS) : 0
329
-
330
- if (actualTokens > 0 && generationMs > 0) {
331
- stats.totalOutputTokens += actualTokens
332
- stats.totalDurationMs += generationMs
333
-
334
- const estimated = tracker.estimatedTokensByMessage[info.id] ?? 0
335
- if (estimated > 0) {
336
- const ratio = Math.min(Math.max(actualTokens / estimated, 0.3), 3.0)
337
- stats.calibrationRatios = [...stats.calibrationRatios, ratio].slice(-MAX_CALIBRATION_RATIOS)
400
+ const live = sessionID !== undefined ? liveTps(tracker, sessionID, now) : undefined
401
+ const avg = sessionID !== undefined ? avgTps(tracker, sessionID) : undefined
402
+ const ttft = sessionID !== undefined ? tracker.liveTtftBySession[sessionID] : undefined
403
+ const liveText = live !== undefined ? formatTps(live) : undefined
404
+ const avgText = avg !== undefined ? formatTps(avg) : undefined
405
+ const ttftText = ttft !== undefined ? formatTtft(ttft) : undefined
406
+ push("TPS ", "muted")
407
+ if (liveText) push(liveText, speedToken(speedTier(live!, options.tps)))
408
+ else push("--", "muted")
409
+ push(" | AVG ", "muted")
410
+ if (avgText) push(avgText, speedToken(speedTier(avg!, options.tps)))
411
+ else push("--", "muted")
412
+ push(" | TTFT ", "muted")
413
+ if (ttftText) push(ttftText, ttftToken(ttftTier(ttft!, options.ttft)))
414
+ else push("--", "muted")
415
+ if (sessionID !== undefined && spinner && options.spinner.enabled && tracker.thinkingBySession[sessionID]) {
416
+ push(" ", "muted")
417
+ const hue = spinner.frame % HUE_PERIOD
418
+ for (let i = 0; i < spinner.cells; i++) {
419
+ const glyph = spinner.glyphs[i] ?? " "
420
+ if (glyph === " ") push(" ", "muted")
421
+ else push(glyph, { cell: i, hue })
338
422
  }
339
423
  }
424
+ return out
425
+ }
340
426
 
341
- delete tracker.estimatedTokensByMessage[info.id]
342
- delete tracker.firstDeltaByMessage[info.id]
343
- delete tracker.lastDeltaByMessage[info.id]
344
- delete tracker.messageCreatedAt[info.id]
345
- delete tracker.activeMessageBySession[sessionID]
346
- delete tracker.samplesBySession[sessionID]
347
- core.setThinking(sessionID, false)
427
+ type Scheduler = {
428
+ addView(view: () => void): () => void
429
+ /** Call after any tracker mutation. `urgent` flushes views synchronously (e.g. first token → TTFT). */
430
+ markDirty(urgent?: boolean): void
431
+ flush(): void
432
+ dispose(): void
433
+ /** Current tick period in ms, or undefined when the timer is stopped (idle). */
434
+ timerPeriod(): number | undefined
435
+ viewCount(): number
348
436
  }
349
437
 
350
- function MeterDisplay(props: {
351
- requestRender: () => void
352
- sessionID: string | undefined
353
- tracker: TrackerState
354
- config: ResolvedConfig
355
- palette: Palette
356
- spinnerCells: () => { glyph: string; color: RGBA }[]
357
- subscribe: (listener: Listener) => () => void
358
- }) {
359
- let particleRef: TextRenderable | undefined
360
- let tpsRef: TextRenderable | undefined
361
- let avgRef: TextRenderable | undefined
362
- let ttftRef: TextRenderable | undefined
363
- const p = props.palette
364
-
365
- const sync = () => {
366
- const stats = props.tracker.statsBySession[props.sessionID]
367
- const calibration = calibrationFactor(stats?.calibrationRatios ?? [])
368
- const samples = props.tracker.samplesBySession[props.sessionID] ?? []
369
- const now = Date.now()
370
- const live = calculateLiveTps(samples, now, calibration)
371
- const avg = stats && stats.totalDurationMs > 0 ? stats.totalOutputTokens / (stats.totalDurationMs / 1000) : undefined
372
- const ttft = props.tracker.liveTtftBySession[props.sessionID]
373
- const thinkingNow = props.tracker.thinkingBySession[props.sessionID] === true && props.config.spinner.enabled
374
- if (particleRef) {
375
- if (thinkingNow) {
376
- const cells = props.spinnerCells()
377
- particleRef.content = " " + cells.map((c) => c.glyph).join("")
378
- particleRef.fg = cells[0]?.color ?? p.muted
379
- } else {
380
- particleRef.content = ""
438
+ function createScheduler(
439
+ tracker: TrackerState,
440
+ options: { spinner: { enabled: boolean; intervalMs: number } },
441
+ spinner: Spinner,
442
+ ): Scheduler {
443
+ const views = new Set<() => void>()
444
+ let timer: ReturnType<typeof setInterval> | undefined
445
+ let period: number | undefined
446
+ let spinning = false
447
+ let disposed = false
448
+
449
+ const flush = () => {
450
+ for (const view of Array.from(views)) {
451
+ try {
452
+ view()
453
+ } catch {
454
+ views.delete(view)
381
455
  }
382
456
  }
383
- if (tpsRef) {
384
- tpsRef.content = live !== undefined ? formatTps(live) ?? "--" : "--"
385
- tpsRef.fg = live !== undefined ? tpsColor(live, props.config.tps, p) : p.muted
386
- }
387
- if (avgRef) {
388
- avgRef.content = avg !== undefined ? formatTps(avg) ?? "--" : "--"
389
- avgRef.fg = avg !== undefined ? tpsColor(avg, props.config.tps, p) : p.muted
390
- }
391
- if (ttftRef) {
392
- ttftRef.content = ttft !== undefined ? formatTtft(ttft) ?? "--" : "--"
393
- ttftRef.fg = ttft !== undefined ? ttftColor(ttft, props.config.ttft, p) : p.muted
457
+ }
458
+ const stop = () => {
459
+ if (timer !== undefined) clearInterval(timer)
460
+ timer = undefined
461
+ period = undefined
462
+ }
463
+ const updateSpinner = (advance: boolean) => {
464
+ const want = options.spinner.enabled && anyThinking(tracker)
465
+ if (want && !spinning) spinner.reset()
466
+ else if (want && advance) spinner.step()
467
+ spinning = want
468
+ }
469
+ const reschedule = (live: boolean) => {
470
+ if (disposed || !live) {
471
+ stop()
472
+ return
394
473
  }
395
- props.requestRender()
474
+ const want = spinning ? options.spinner.intervalMs : IDLE_TICK_MS
475
+ if (timer !== undefined && period === want) return
476
+ stop()
477
+ period = want
478
+ timer = setInterval(tick, want)
479
+ }
480
+ const tick = () => {
481
+ const live = prune(tracker, Date.now())
482
+ updateSpinner(true)
483
+ flush()
484
+ reschedule(live)
396
485
  }
397
486
 
398
- const unsubscribe = props.subscribe(sync)
399
- onCleanup(unsubscribe)
487
+ return {
488
+ addView(view) {
489
+ views.add(view)
490
+ return () => {
491
+ views.delete(view)
492
+ }
493
+ },
494
+ markDirty(urgent = false) {
495
+ if (disposed) return
496
+ const live = prune(tracker, Date.now())
497
+ updateSpinner(false)
498
+ if (urgent || !live) flush()
499
+ reschedule(live)
500
+ },
501
+ flush,
502
+ dispose() {
503
+ disposed = true
504
+ stop()
505
+ views.clear()
506
+ },
507
+ timerPeriod: () => period,
508
+ viewCount: () => views.size,
509
+ }
510
+ }
511
+ // ==== END PURE MEASURE ====
400
512
 
401
- return (
402
- <box flexDirection="row" alignItems="center">
403
- <text fg={p.muted}>TPS </text>
404
- <text ref={(el: TextRenderable) => { tpsRef = el; sync() }} fg={p.muted}>--</text>
405
- <text fg={p.muted}> | AVG </text>
406
- <text ref={(el: TextRenderable) => { avgRef = el; sync() }} fg={p.muted}>--</text>
407
- <text fg={p.muted}> | TTFT </text>
408
- <text ref={(el: TextRenderable) => { ttftRef = el; sync() }} fg={p.muted}>--</text>
409
- <text ref={(el: TextRenderable) => { particleRef = el; sync() }} fg={p.muted}></text>
410
- </box>
411
- )
513
+ // ---- RGBA helpers (host-dependent, outside the pure region) ----
514
+
515
+ type Palette = { muted: RGBA; error: RGBA; warning: RGBA; success: RGBA; accent: RGBA }
516
+
517
+ function lerp(a: number, b: number, t: number): number {
518
+ return a + (b - a) * t
519
+ }
520
+ function lerpColor(a: RGBA, b: RGBA, t: number): RGBA {
521
+ return RGBA.fromValues(lerp(a.r, b.r, t), lerp(a.g, b.g, t), lerp(a.b, b.b, t), 1)
522
+ }
523
+ function gradientColor(colors: RGBA[], t: number): RGBA {
524
+ if (colors.length === 0) return RGBA.fromHex("#888888")
525
+ if (colors.length === 1) return colors[0]
526
+ const c = Math.max(0, Math.min(1, t))
527
+ const seg = c * (colors.length - 1)
528
+ const i = Math.min(Math.floor(seg), colors.length - 2)
529
+ return lerpColor(colors[i], colors[i + 1], seg - i)
412
530
  }
413
531
 
414
- const tui: TuiPlugin = async (api, options) => {
415
- const config = resolveConfig(options)
416
- const core = createMeterCore(config, api.theme.current.textMuted)
417
- const { tracker, bump, subscribe, spinnerCells, setThinking, clearSessionLive } = core
418
-
419
- const onTextDelta = api.event.on("session.next.text.delta", (evt) => {
420
- tracker.useV2Events = true
421
- const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
422
- recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
423
- setThinking(sessionID, false)
424
- bump()
425
- })
532
+ type V2Event = { type: string; created: number; data: Record<string, any> }
533
+ type V2Theme = {
534
+ text: {
535
+ muted: RGBA
536
+ feedback: Record<"error" | "warning" | "success" | "info", { base: RGBA; muted: RGBA }>
537
+ }
538
+ hue: Record<string, Partial<Record<number, RGBA>>>
539
+ }
540
+ type V2SlotInput = { readonly sessionID?: string }
541
+ type V2Context = {
542
+ options: Record<string, unknown> | undefined
543
+ renderer: { requestRender(): void }
544
+ theme: V2Theme
545
+ data: { on(type: string, handler: (event: V2Event) => void): () => void }
546
+ ui: {
547
+ slot(claim: { append: string; render: (input: V2SlotInput) => unknown }): () => void
548
+ router: { current(): { type: string; sessionID?: string } }
549
+ }
550
+ }
426
551
 
427
- const onReasoningDelta = api.event.on("session.next.reasoning.delta", (evt) => {
428
- tracker.useV2Events = true
429
- const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
430
- recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
431
- setThinking(sessionID, true)
432
- bump()
433
- })
552
+ function v1Palette(t: TuiThemeCurrent): Palette {
553
+ return { muted: t.textMuted, error: t.error, warning: t.warning, success: t.success, accent: t.accent }
554
+ }
555
+ function v2Palette(t: V2Theme): Palette {
556
+ return {
557
+ muted: t.text.muted,
558
+ error: t.text.feedback.error.base,
559
+ warning: t.text.feedback.warning.base,
560
+ success: t.text.feedback.success.base,
561
+ accent: t.hue.accent?.[500] ?? t.text.feedback.info.base,
562
+ }
563
+ }
434
564
 
435
- const onPartDelta = api.event.on("message.part.delta", (evt) => {
436
- if (tracker.useV2Events) return
437
- if (evt.properties.field !== "text") return
438
- const parts = api.state.part(evt.properties.messageID)
439
- const part = parts.find((p) => p.id === evt.properties.partID)
440
- if (!part || (part.type !== "text" && part.type !== "reasoning")) return
441
- const { sessionID, messageID } = evt.properties
442
- recordDelta(tracker, sessionID, messageID, Date.now(), evt.properties.delta)
443
- setThinking(sessionID, part.type === "reasoning")
444
- bump()
445
- })
565
+ // ---- Core: tracker + scheduler shared by every view of one host ----
566
+
567
+ type MeterCore = {
568
+ tracker: TrackerState
569
+ options: ResolvedOptions
570
+ spinner: Spinner
571
+ scheduler: Scheduler
572
+ gradient: RGBA[]
573
+ }
574
+
575
+ function createMeterCore(rawOptions: unknown): MeterCore {
576
+ const options = resolveOptions(rawOptions)
577
+ const tracker = createTracker()
578
+ const spinner = createSpinner(options.spinner.cells)
579
+ const scheduler = createScheduler(tracker, options, spinner)
580
+ const gradient = options.spinner.colors.map((c) => RGBA.fromHex(c))
581
+ return { tracker, options, spinner, scheduler, gradient }
582
+ }
583
+
584
+ function resolveColor(token: ColorToken, palette: Palette, core: MeterCore): RGBA {
585
+ if (typeof token === "string") return palette[token]
586
+ const bright = gradientColor(core.gradient, token.hue / HUE_PERIOD)
587
+ return lerpColor(bright, palette.muted, token.cell / Math.max(1, core.spinner.cells - 1))
588
+ }
589
+
590
+ /** Wraps an event handler so a malformed payload never throws into the host. */
591
+ function guard<E>(fn: (evt: E) => void): (evt: E) => void {
592
+ return (evt) => {
593
+ try {
594
+ fn(evt)
595
+ } catch {}
596
+ }
597
+ }
446
598
 
447
- const onMessageUpdated = api.event.on("message.updated", (evt) => {
448
- const info = evt.properties.info
449
- if (info.role !== "assistant") return
599
+ // ---- View: one <text>, StyledText content, imperative + diffed (AGENTS.md gotchas #2/#3) ----
450
600
 
451
- if (!info.time.completed) {
452
- tracker.messageCreatedAt[info.id] = info.time.created
601
+ function MeterDisplay(props: {
602
+ core: MeterCore
603
+ palette: Palette
604
+ sessionID: () => string | undefined
605
+ requestRender: () => void
606
+ }) {
607
+ const { core, palette } = props
608
+ let ref: TextRenderable | undefined
609
+ let lastKey = ""
610
+ let unsubscribe = () => {}
611
+
612
+ const flush = () => {
613
+ if (!ref) return
614
+ if (ref.isDestroyed) {
615
+ unsubscribe()
453
616
  return
454
617
  }
618
+ const segments = buildSegments(core.tracker, props.sessionID(), Date.now(), core.options, core.spinner)
619
+ const key = segmentsKey(segments)
620
+ if (key === lastKey) return
621
+ lastKey = key
622
+ ref.content = new StyledText(
623
+ segments.map((s) => ({ __isChunk: true as const, text: s.text, fg: resolveColor(s.color, palette, core) })),
624
+ )
625
+ props.requestRender()
626
+ }
455
627
 
456
- const sessionID = info.sessionID ?? evt.properties.sessionID
457
- recordMessageCompleted(core, { id: info.id, sessionID, tokens: info.tokens })
458
- bump()
459
- })
628
+ unsubscribe = core.scheduler.addView(flush)
629
+ onCleanup(() => unsubscribe())
460
630
 
461
- const onToolInputStarted = api.event.on("session.next.tool.input.started", (evt) => {
462
- clearSessionLive(evt.properties.sessionID)
463
- bump()
464
- })
631
+ return (
632
+ <text
633
+ ref={(el: TextRenderable) => {
634
+ ref = el
635
+ lastKey = ""
636
+ flush()
637
+ }}
638
+ fg={palette.muted}
639
+ wrapMode="none"
640
+ />
641
+ )
642
+ }
465
643
 
466
- const onPartUpdated = api.event.on("message.part.updated", (evt) => {
467
- if (evt.properties.part.type !== "tool") return
468
- if (["running", "completed", "error"].includes(evt.properties.part.state.status)) {
469
- clearSessionLive(evt.properties.part.sessionID ?? evt.properties.sessionID)
470
- bump()
471
- }
472
- })
644
+ // ---- V1 host (opencode 1.x): `tui` entrypoint ----
473
645
 
474
- const timer = setInterval(() => {
475
- const now = Date.now()
476
- const cutoff = now - WINDOW_MS
477
- for (const [sid, samples] of Object.entries(tracker.samplesBySession)) {
478
- const pruned = samples.filter((s) => s.at >= cutoff)
479
- if (pruned.length > 0) tracker.samplesBySession[sid] = pruned
480
- else delete tracker.samplesBySession[sid]
481
- }
482
- bump()
483
- }, 1000)
646
+ const tui: TuiPlugin = async (api, options) => {
647
+ const core = createMeterCore(options)
648
+ const { tracker, scheduler } = core
649
+ const dirty = (urgent = false) => scheduler.markDirty(urgent)
650
+
651
+ const offs = [
652
+ api.event.on("session.next.text.delta", guard((evt) => {
653
+ tracker.useV2Events = true
654
+ const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
655
+ dirty(recordDelta(tracker, { sessionID, messageID: assistantMessageID, kind: "text", delta, at: Date.now(), serverAt: timestamp }))
656
+ })),
657
+ api.event.on("session.next.reasoning.delta", guard((evt) => {
658
+ tracker.useV2Events = true
659
+ const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
660
+ dirty(recordDelta(tracker, { sessionID, messageID: assistantMessageID, kind: "reasoning", delta, at: Date.now(), serverAt: timestamp }))
661
+ })),
662
+ api.event.on("session.next.tool.input.delta", guard((evt) => {
663
+ tracker.useV2Events = true
664
+ const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
665
+ dirty(recordDelta(tracker, { sessionID, messageID: assistantMessageID, kind: "tool", delta, at: Date.now(), serverAt: timestamp }))
666
+ })),
667
+ api.event.on("message.part.delta", guard((evt) => {
668
+ if (tracker.useV2Events) return
669
+ if (evt.properties.field !== "text") return
670
+ const { sessionID, messageID, partID, delta } = evt.properties
671
+ const part = api.state.part(messageID).find((p) => p.id === partID)
672
+ if (!part || (part.type !== "text" && part.type !== "reasoning")) return
673
+ dirty(recordDelta(tracker, { sessionID, messageID, kind: part.type, delta, at: Date.now() }))
674
+ })),
675
+ api.event.on("message.updated", guard((evt) => {
676
+ const info = evt.properties.info
677
+ if (info.role !== "assistant") return
678
+ const sessionID = info.sessionID ?? evt.properties.sessionID
679
+ if (!info.time.completed) {
680
+ if (recordMessageStart(tracker, info.id, info.time.created)) {
681
+ clearSessionLive(tracker, sessionID)
682
+ dirty()
683
+ }
684
+ return
685
+ }
686
+ recordMessageCompleted(tracker, { id: info.id, sessionID, tokens: info.tokens, finish: info.finish })
687
+ dirty()
688
+ })),
689
+ api.event.on("session.next.tool.input.started", guard((evt) => {
690
+ setThinking(tracker, evt.properties.sessionID, false)
691
+ dirty()
692
+ })),
693
+ api.event.on("message.part.updated", guard((evt) => {
694
+ const part = evt.properties.part
695
+ if (part.type !== "tool") return
696
+ if (part.state.status === "running" || part.state.status === "completed" || part.state.status === "error") {
697
+ recordToolBoundary(tracker, part.messageID)
698
+ setThinking(tracker, part.sessionID ?? evt.properties.sessionID, false)
699
+ dirty()
700
+ }
701
+ })),
702
+ api.event.on("session.idle", guard((evt) => {
703
+ clearSessionLive(tracker, evt.properties.sessionID)
704
+ dirty()
705
+ })),
706
+ api.event.on("session.error", guard((evt) => {
707
+ if (!evt.properties.sessionID) return
708
+ clearSessionLive(tracker, evt.properties.sessionID)
709
+ dirty()
710
+ })),
711
+ ]
484
712
 
485
713
  api.lifecycle.onDispose(() => {
486
- onTextDelta(); onReasoningDelta(); onPartDelta()
487
- onMessageUpdated(); onToolInputStarted(); onPartUpdated()
488
- clearInterval(timer)
489
- core.dispose()
714
+ for (const off of offs) off()
715
+ scheduler.dispose()
490
716
  })
491
717
 
492
718
  api.slots.register({
@@ -494,13 +720,10 @@ const tui: TuiPlugin = async (api, options) => {
494
720
  session_prompt_right(_ctx, value) {
495
721
  return (
496
722
  <MeterDisplay
497
- requestRender={() => api.renderer.requestRender()}
498
- sessionID={value.session_id}
499
- tracker={tracker}
500
- config={config}
723
+ core={core}
501
724
  palette={v1Palette(api.theme.current)}
502
- spinnerCells={spinnerCells}
503
- subscribe={subscribe}
725
+ sessionID={() => value.session_id}
726
+ requestRender={() => api.renderer.requestRender()}
504
727
  />
505
728
  )
506
729
  },
@@ -508,89 +731,82 @@ const tui: TuiPlugin = async (api, options) => {
508
731
  })
509
732
  }
510
733
 
734
+ // ---- V2 host (opencode 2.x): `setup` entrypoint ----
735
+
511
736
  const setup = async (ctx: V2Context): Promise<() => void> => {
512
- const config = resolveConfig(ctx.options as PluginOptions | undefined)
513
- const core = createMeterCore(config, ctx.theme.text.muted)
514
- const { tracker, bump, subscribe, spinnerCells, setThinking, clearSessionLive } = core
737
+ const core = createMeterCore(ctx.options)
738
+ const { tracker, scheduler } = core
739
+ const dirty = (urgent = false) => scheduler.markDirty(urgent)
515
740
 
516
- const unsubs: (() => void)[] = []
741
+ const offs: (() => void)[] = []
517
742
  const on = (type: string, handler: (event: V2Event) => void) => {
518
- unsubs.push(ctx.data.on(type, handler))
743
+ offs.push(ctx.data.on(type, guard(handler)))
519
744
  }
520
745
 
521
746
  on("session.step.started", (evt) => {
522
- tracker.messageCreatedAt[evt.data.assistantMessageID] = evt.data.started
747
+ const { sessionID, assistantMessageID, started } = evt.data
748
+ recordMessageStart(tracker, assistantMessageID, started)
749
+ clearSessionLive(tracker, sessionID)
750
+ dirty()
523
751
  })
524
752
 
525
- const onDelta = (thinking: boolean) => (evt: V2Event) => {
753
+ const onDelta = (kind: DeltaKind) => (evt: V2Event) => {
526
754
  const { sessionID, assistantMessageID, delta } = evt.data
527
- recordDelta(tracker, sessionID, assistantMessageID, evt.created, delta)
528
- setThinking(sessionID, thinking)
529
- bump()
755
+ dirty(recordDelta(tracker, { sessionID, messageID: assistantMessageID, kind, delta, at: Date.now(), serverAt: evt.created }))
530
756
  }
531
- on("session.text.delta", onDelta(false))
532
- on("session.reasoning.delta", onDelta(true))
757
+ on("session.text.delta", onDelta("text"))
758
+ on("session.reasoning.delta", onDelta("reasoning"))
759
+ on("session.tool.input.delta", onDelta("tool"))
533
760
 
534
761
  on("session.step.ended", (evt) => {
535
- const { sessionID, assistantMessageID, tokens } = evt.data
536
- if (tokens) recordMessageCompleted(core, { id: assistantMessageID, sessionID, tokens })
537
- bump()
762
+ const { sessionID, assistantMessageID, tokens, finish } = evt.data
763
+ if (tokens) recordMessageCompleted(tracker, { id: assistantMessageID, sessionID, tokens, finish })
764
+ else discardMessage(tracker, assistantMessageID)
765
+ dirty()
538
766
  })
539
767
 
540
768
  on("session.step.failed", (evt) => {
541
769
  const { sessionID, assistantMessageID } = evt.data
542
- delete tracker.messageCreatedAt[assistantMessageID]
543
- delete tracker.estimatedTokensByMessage[assistantMessageID]
544
- delete tracker.firstDeltaByMessage[assistantMessageID]
545
- delete tracker.lastDeltaByMessage[assistantMessageID]
546
- clearSessionLive(sessionID)
547
- bump()
770
+ discardMessage(tracker, assistantMessageID)
771
+ clearSessionLive(tracker, sessionID)
772
+ dirty()
548
773
  })
549
774
 
550
775
  on("session.tool.input.started", (evt) => {
551
- clearSessionLive(evt.data.sessionID)
552
- bump()
776
+ setThinking(tracker, evt.data.sessionID, false)
777
+ dirty()
553
778
  })
554
779
 
555
- const timer = setInterval(() => {
556
- const now = Date.now()
557
- const cutoff = now - WINDOW_MS
558
- for (const [sid, samples] of Object.entries(tracker.samplesBySession)) {
559
- const pruned = samples.filter((s) => s.at >= cutoff)
560
- if (pruned.length > 0) tracker.samplesBySession[sid] = pruned
561
- else delete tracker.samplesBySession[sid]
562
- }
563
- bump()
564
- }, 1000)
780
+ for (const type of ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"]) {
781
+ on(type, (evt) => {
782
+ clearSessionLive(tracker, evt.data.sessionID)
783
+ dirty()
784
+ })
785
+ }
565
786
 
566
- const opts = (ctx.options ?? {}) as { slot?: string }
567
- const slotPath = typeof opts.slot === "string" && opts.slot ? opts.slot : "prompt.footer.status"
787
+ const routeSessionID = () => {
788
+ const r = ctx.ui.router.current()
789
+ return r.type === "session" ? r.sessionID : undefined
790
+ }
568
791
 
569
- ctx.ui.slot({
570
- append: slotPath,
792
+ const unregisterSlot = ctx.ui.slot({
793
+ append: core.options.slot,
571
794
  render(input) {
572
- const sessionID = input.sessionID ?? (() => {
573
- const r = ctx.ui.router.current()
574
- return r.type === "session" ? r.sessionID : undefined
575
- })()
576
795
  return (
577
796
  <MeterDisplay
578
- requestRender={() => ctx.renderer.requestRender()}
579
- sessionID={sessionID}
580
- tracker={tracker}
581
- config={config}
797
+ core={core}
582
798
  palette={v2Palette(ctx.theme)}
583
- spinnerCells={spinnerCells}
584
- subscribe={subscribe}
799
+ sessionID={() => input.sessionID ?? routeSessionID()}
800
+ requestRender={() => ctx.renderer.requestRender()}
585
801
  />
586
802
  )
587
803
  },
588
804
  })
589
805
 
590
806
  return () => {
591
- for (const u of unsubs) u()
592
- clearInterval(timer)
593
- core.dispose()
807
+ for (const off of offs) off()
808
+ if (typeof unregisterSlot === "function") unregisterSlot()
809
+ scheduler.dispose()
594
810
  }
595
811
  }
596
812