@jack-yang/opencode-tps-meter 1.1.2 → 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,37 +84,24 @@ function formatTtft(valueMs: number): string | undefined {
106
84
  }
107
85
 
108
86
  type SpeedTier = "slow" | "normal" | "fast" | "faster"
109
- function speedTier(tps: number, c: TpsTiers): SpeedTier {
110
- if (tps < c.slow) return "slow"
111
- if (tps < c.normal) return "normal"
112
- if (tps < c.fast) return "fast"
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 }
92
+
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"
113
97
  return "faster"
114
98
  }
115
- function tierColor(tier: SpeedTier, theme: TuiThemeCurrent): RGBA {
116
- switch (tier) {
117
- case "slow": return theme.error
118
- case "normal": return theme.warning
119
- case "fast": return theme.success
120
- case "faster": return theme.accent
121
- }
122
- }
123
- function tpsColor(tps: number, c: TpsTiers, theme: TuiThemeCurrent): RGBA {
124
- return tierColor(speedTier(tps, c), theme)
125
- }
126
99
 
127
- type TtftTier = "fast" | "ok" | "slow"
128
- function ttftTier(ms: number, c: TtftTiers): TtftTier {
129
- if (ms < c.fast) return "fast"
130
- 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"
131
103
  return "slow"
132
104
  }
133
- function ttftColor(ms: number, c: TtftTiers, theme: TuiThemeCurrent): RGBA {
134
- switch (ttftTier(ms, c)) {
135
- case "fast": return theme.success
136
- case "ok": return theme.warning
137
- case "slow": return theme.error
138
- }
139
- }
140
105
 
141
106
  function median(values: number[]): number {
142
107
  if (values.length === 0) return 1.0
@@ -145,6 +110,7 @@ function median(values: number[]): number {
145
110
  if (sorted.length % 2 !== 0) return sorted[mid]
146
111
  return Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 1e10) / 1e10
147
112
  }
113
+
148
114
  function calibrationFactor(ratios: number[]): number {
149
115
  return ratios.length === 0 ? 1.0 : median(ratios)
150
116
  }
@@ -162,264 +128,591 @@ function calculateLiveTps(samples: DeltaSample[], now: number, calibration: numb
162
128
  return (totalRaw * calibration) / (durationMs / 1000)
163
129
  }
164
130
 
165
- function recordDelta(tracker: TrackerState, sessionID: string, messageID: string, at: number, delta: string) {
166
- const rawTokens = estimateTokens(delta)
167
- const cutoff = at - WINDOW_MS
168
- const existing = tracker.samplesBySession[sessionID] ?? []
169
- tracker.samplesBySession[sessionID] = [...existing.filter((s) => s.at >= cutoff), { at, rawTokens }]
170
- if (tracker.firstDeltaByMessage[messageID] === undefined) {
171
- tracker.firstDeltaByMessage[messageID] = at
172
- const created = tracker.messageCreatedAt[messageID]
173
- if (created !== undefined) {
174
- 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)
175
201
  }
176
202
  }
177
- tracker.lastDeltaByMessage[messageID] = at
178
- tracker.estimatedTokensByMessage[messageID] = (tracker.estimatedTokensByMessage[messageID] ?? 0) + rawTokens
179
- 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
180
212
  }
181
213
 
182
- 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
+ }
183
218
 
184
- function MeterDisplay(props: {
185
- api: Parameters<TuiPlugin>[0]
219
+ function clearSessionLive(tracker: TrackerState, sessionID: string): void {
220
+ delete tracker.samplesBySession[sessionID]
221
+ setThinking(tracker, sessionID, false)
222
+ }
223
+
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]
228
+ }
229
+ }
230
+
231
+ type CompletedMessage = {
232
+ id: string
186
233
  sessionID: string
187
- tracker: TrackerState
188
- config: ResolvedConfig
189
- muted: RGBA
190
- spinnerCells: () => { glyph: string; color: RGBA }[]
191
- subscribe: (listener: Listener) => () => void
192
- }) {
193
- let particleRef: TextRenderable | undefined
194
- let tpsRef: TextRenderable | undefined
195
- let avgRef: TextRenderable | undefined
196
- let ttftRef: TextRenderable | undefined
197
- const theme = props.api.theme.current
198
- const muted = props.muted
199
-
200
- const sync = () => {
201
- const stats = props.tracker.statsBySession[props.sessionID]
202
- const calibration = calibrationFactor(stats?.calibrationRatios ?? [])
203
- const samples = props.tracker.samplesBySession[props.sessionID] ?? []
204
- const now = Date.now()
205
- const live = calculateLiveTps(samples, now, calibration)
206
- const avg = stats && stats.totalDurationMs > 0 ? stats.totalOutputTokens / (stats.totalDurationMs / 1000) : undefined
207
- const ttft = props.tracker.liveTtftBySession[props.sessionID]
208
- const thinkingNow = props.tracker.thinkingBySession[props.sessionID] === true && props.config.spinner.enabled
209
- if (particleRef) {
210
- if (thinkingNow) {
211
- const cells = props.spinnerCells()
212
- particleRef.content = " " + cells.map((c) => c.glyph).join("")
213
- particleRef.fg = cells[0]?.color ?? muted
214
- } else {
215
- particleRef.content = ""
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
+ }
216
258
  }
217
259
  }
218
- if (tpsRef) {
219
- tpsRef.content = live !== undefined ? formatTps(live) ?? "--" : "--"
220
- tpsRef.fg = live !== undefined ? tpsColor(live, props.config.tps, theme) : muted
221
- }
222
- if (avgRef) {
223
- avgRef.content = avg !== undefined ? formatTps(avg) ?? "--" : "--"
224
- avgRef.fg = avg !== undefined ? tpsColor(avg, props.config.tps, theme) : muted
225
- }
226
- if (ttftRef) {
227
- ttftRef.content = ttft !== undefined ? formatTtft(ttft) ?? "--" : "--"
228
- ttftRef.fg = ttft !== undefined ? ttftColor(ttft, props.config.ttft, theme) : muted
260
+ delete tracker.messages[info.id]
261
+ }
262
+ const active = tracker.activeMessageBySession[sessionID]
263
+ if (active === undefined || active === info.id) {
264
+ delete tracker.activeMessageBySession[sessionID]
265
+ clearSessionLive(tracker, sessionID)
266
+ }
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
229
281
  }
230
- props.api.renderer.requestRender()
231
282
  }
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
287
+ }
288
+ return live
289
+ }
232
290
 
233
- const unsubscribe = props.subscribe(sync)
234
- onCleanup(unsubscribe)
291
+ type SpinnerOptions = { colors: string[]; intervalMs: number; enabled: boolean; cells: number }
292
+ type ResolvedOptions = { spinner: SpinnerOptions; tps: TpsTiers; ttft: TtftTiers; slot: string }
235
293
 
236
- return (
237
- <box flexDirection="row" alignItems="center">
238
- <text fg={muted}>TPS </text>
239
- <text ref={(el: TextRenderable) => { tpsRef = el; sync() }} fg={muted}>--</text>
240
- <text fg={muted}> | AVG </text>
241
- <text ref={(el: TextRenderable) => { avgRef = el; sync() }} fg={muted}>--</text>
242
- <text fg={muted}> | TTFT </text>
243
- <text ref={(el: TextRenderable) => { ttftRef = el; sync() }} fg={muted}>--</text>
244
- <text ref={(el: TextRenderable) => { particleRef = el; sync() }} fg={muted}></text>
245
- </box>
246
- )
294
+ const THEME_PRESETS: Record<string, string[]> = {
295
+ tech: ["#00e5ff", "#2979ff", "#651fff"],
296
+ red: ["#ff1744", "#ff5252", "#ff8a80"],
247
297
  }
298
+ const HEX_COLOR = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i
248
299
 
249
- const tui: TuiPlugin = async (api, options) => {
250
- const config = resolveConfig(options)
251
- const muted = api.theme.current.textMuted
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
+ }
252
304
 
253
- const tracker: TrackerState = {
254
- useV2Events: false,
255
- samplesBySession: {},
256
- messageCreatedAt: {},
257
- firstDeltaByMessage: {},
258
- lastDeltaByMessage: {},
259
- estimatedTokensByMessage: {},
260
- liveTtftBySession: {},
261
- activeMessageBySession: {},
262
- statsBySession: {},
263
- thinkingBySession: {},
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
314
+ }
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)),
324
+ }
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),
264
332
  }
265
- const listeners = new Set<Listener>()
266
- const bump = () => { for (const l of listeners) l() }
267
- const subscribe = (listener: Listener) => { listeners.add(listener); return () => { listeners.delete(listener) } }
268
-
269
- const N = config.spinner.cells
270
- const randomDot = () => DOT_GLYPHS[Math.floor(Math.random() * DOT_GLYPHS.length)]
271
- const spawnGlyph = () => (Math.random() < 0.18 ? " " : randomDot())
272
- let stream: string[] = []
273
- let hueFrame = 0
274
- let spinnerTimer: ReturnType<typeof setInterval> | undefined
275
-
276
- const initStream = () => {
277
- stream = Array.from({ length: N }, () => spawnGlyph())
278
- hueFrame = 0
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
+ }
339
+
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++
358
+ },
279
359
  }
280
- const tickStream = () => {
281
- stream.unshift(spawnGlyph())
282
- if (stream.length > N) stream.pop()
283
- hueFrame++
360
+ s.reset()
361
+ return s
362
+ }
363
+
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 })
284
399
  }
285
- initStream()
286
-
287
- const spinnerCells = () => {
288
- const bright = gradientColor(config.spinner.colors, (hueFrame % 60) / 60)
289
- const out: { glyph: string; color: RGBA }[] = []
290
- for (let i = 0; i < N; i++) {
291
- const g = stream[i] ?? " "
292
- out.push({ glyph: g, color: g === " " ? muted : lerpColor(bright, muted, i / (N - 1)) })
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 })
293
422
  }
294
- return out
295
423
  }
424
+ return out
425
+ }
426
+
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
436
+ }
296
437
 
297
- const anyThinking = () => {
298
- for (const v of Object.values(tracker.thinkingBySession)) if (v) return true
299
- return false
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)
455
+ }
456
+ }
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
300
468
  }
301
- const syncSpinnerTimer = () => {
302
- if (config.spinner.enabled && anyThinking() && !spinnerTimer) {
303
- spinnerTimer = setInterval(() => {
304
- tickStream()
305
- bump()
306
- }, config.spinner.intervalMs)
307
- } else if ((!anyThinking() || !config.spinner.enabled) && spinnerTimer) {
308
- clearInterval(spinnerTimer)
309
- spinnerTimer = undefined
469
+ const reschedule = (live: boolean) => {
470
+ if (disposed || !live) {
471
+ stop()
472
+ return
310
473
  }
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)
311
479
  }
312
- const setThinking = (sessionID: string, value: boolean) => {
313
- if (tracker.thinkingBySession[sessionID] === value) return
314
- tracker.thinkingBySession[sessionID] = value
315
- if (value) initStream()
316
- syncSpinnerTimer()
480
+ const tick = () => {
481
+ const live = prune(tracker, Date.now())
482
+ updateSpinner(true)
483
+ flush()
484
+ reschedule(live)
317
485
  }
318
486
 
319
- const onTextDelta = api.event.on("session.next.text.delta", (evt) => {
320
- tracker.useV2Events = true
321
- const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
322
- recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
323
- setThinking(sessionID, false)
324
- bump()
325
- })
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 ====
326
512
 
327
- const onReasoningDelta = api.event.on("session.next.reasoning.delta", (evt) => {
328
- tracker.useV2Events = true
329
- const { sessionID, assistantMessageID, timestamp, delta } = evt.properties
330
- recordDelta(tracker, sessionID, assistantMessageID, timestamp, delta)
331
- setThinking(sessionID, true)
332
- bump()
333
- })
513
+ // ---- RGBA helpers (host-dependent, outside the pure region) ----
334
514
 
335
- const onPartDelta = api.event.on("message.part.delta", (evt) => {
336
- if (tracker.useV2Events) return
337
- if (evt.properties.field !== "text") return
338
- const parts = api.state.part(evt.properties.messageID)
339
- const part = parts.find((p) => p.id === evt.properties.partID)
340
- if (!part || (part.type !== "text" && part.type !== "reasoning")) return
341
- const { sessionID, messageID } = evt.properties
342
- recordDelta(tracker, sessionID, messageID, Date.now(), evt.properties.delta)
343
- setThinking(sessionID, part.type === "reasoning")
344
- bump()
345
- })
515
+ type Palette = { muted: RGBA; error: RGBA; warning: RGBA; success: RGBA; accent: RGBA }
346
516
 
347
- const onMessageUpdated = api.event.on("message.updated", (evt) => {
348
- const info = evt.properties.info
349
- if (info.role !== "assistant") return
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)
530
+ }
350
531
 
351
- if (!info.time.completed) {
352
- tracker.messageCreatedAt[info.id] = info.time.created
353
- return
354
- }
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
+ }
355
551
 
356
- const sessionID = info.sessionID ?? evt.properties.sessionID
357
- if (!tracker.statsBySession[sessionID]) {
358
- tracker.statsBySession[sessionID] = { totalOutputTokens: 0, totalDurationMs: 0, calibrationRatios: [] }
359
- }
360
- const stats = tracker.statsBySession[sessionID]!
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
+ }
361
564
 
362
- const actualTokens = info.tokens.output + info.tokens.reasoning
363
- const firstDelta = tracker.firstDeltaByMessage[info.id]
364
- const lastDelta = tracker.lastDeltaByMessage[info.id]
365
- const generationMs = firstDelta !== undefined && lastDelta !== undefined
366
- ? Math.max(lastDelta - firstDelta, MIN_DURATION_MS) : 0
565
+ // ---- Core: tracker + scheduler shared by every view of one host ----
367
566
 
368
- if (actualTokens > 0 && generationMs > 0) {
369
- stats.totalOutputTokens += actualTokens
370
- stats.totalDurationMs += generationMs
567
+ type MeterCore = {
568
+ tracker: TrackerState
569
+ options: ResolvedOptions
570
+ spinner: Spinner
571
+ scheduler: Scheduler
572
+ gradient: RGBA[]
573
+ }
371
574
 
372
- const estimated = tracker.estimatedTokensByMessage[info.id] ?? 0
373
- if (estimated > 0) {
374
- const ratio = Math.min(Math.max(actualTokens / estimated, 0.3), 3.0)
375
- stats.calibrationRatios = [...stats.calibrationRatios, ratio].slice(-MAX_CALIBRATION_RATIOS)
376
- }
377
- }
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
+ }
378
583
 
379
- delete tracker.estimatedTokensByMessage[info.id]
380
- delete tracker.firstDeltaByMessage[info.id]
381
- delete tracker.lastDeltaByMessage[info.id]
382
- delete tracker.messageCreatedAt[info.id]
383
- delete tracker.activeMessageBySession[sessionID]
384
- delete tracker.samplesBySession[sessionID]
385
- setThinking(sessionID, false)
386
- bump()
387
- })
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
+ }
388
589
 
389
- const clearSessionLive = (sessionID: string) => {
390
- delete tracker.samplesBySession[sessionID]
391
- setThinking(sessionID, false)
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 {}
392
596
  }
597
+ }
393
598
 
394
- const onToolInputStarted = api.event.on("session.next.tool.input.started", (evt) => {
395
- clearSessionLive(evt.properties.sessionID)
396
- bump()
397
- })
599
+ // ---- View: one <text>, StyledText content, imperative + diffed (AGENTS.md gotchas #2/#3) ----
398
600
 
399
- const onPartUpdated = api.event.on("message.part.updated", (evt) => {
400
- if (evt.properties.part.type !== "tool") return
401
- if (["running", "completed", "error"].includes(evt.properties.part.state.status)) {
402
- clearSessionLive(evt.properties.part.sessionID ?? evt.properties.sessionID)
403
- bump()
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()
616
+ return
404
617
  }
405
- })
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
+ }
406
627
 
407
- const timer = setInterval(() => {
408
- const now = Date.now()
409
- const cutoff = now - WINDOW_MS
410
- for (const [sid, samples] of Object.entries(tracker.samplesBySession)) {
411
- const pruned = samples.filter((s) => s.at >= cutoff)
412
- if (pruned.length > 0) tracker.samplesBySession[sid] = pruned
413
- else delete tracker.samplesBySession[sid]
414
- }
415
- bump()
416
- }, 1000)
628
+ unsubscribe = core.scheduler.addView(flush)
629
+ onCleanup(() => unsubscribe())
630
+
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
+ }
643
+
644
+ // ---- V1 host (opencode 1.x): `tui` entrypoint ----
645
+
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
+ ]
417
712
 
418
713
  api.lifecycle.onDispose(() => {
419
- onTextDelta(); onReasoningDelta(); onPartDelta()
420
- onMessageUpdated(); onToolInputStarted(); onPartUpdated()
421
- clearInterval(timer)
422
- if (spinnerTimer) clearInterval(spinnerTimer)
714
+ for (const off of offs) off()
715
+ scheduler.dispose()
423
716
  })
424
717
 
425
718
  api.slots.register({
@@ -427,13 +720,10 @@ const tui: TuiPlugin = async (api, options) => {
427
720
  session_prompt_right(_ctx, value) {
428
721
  return (
429
722
  <MeterDisplay
430
- api={api}
431
- sessionID={value.session_id}
432
- tracker={tracker}
433
- config={config}
434
- muted={muted}
435
- spinnerCells={spinnerCells}
436
- subscribe={subscribe}
723
+ core={core}
724
+ palette={v1Palette(api.theme.current)}
725
+ sessionID={() => value.session_id}
726
+ requestRender={() => api.renderer.requestRender()}
437
727
  />
438
728
  )
439
729
  },
@@ -441,5 +731,88 @@ const tui: TuiPlugin = async (api, options) => {
441
731
  })
442
732
  }
443
733
 
444
- const plugin: TuiPluginModule & { id: string } = { id: "opencode-tps-meter", tui }
734
+ // ---- V2 host (opencode 2.x): `setup` entrypoint ----
735
+
736
+ const setup = async (ctx: V2Context): Promise<() => void> => {
737
+ const core = createMeterCore(ctx.options)
738
+ const { tracker, scheduler } = core
739
+ const dirty = (urgent = false) => scheduler.markDirty(urgent)
740
+
741
+ const offs: (() => void)[] = []
742
+ const on = (type: string, handler: (event: V2Event) => void) => {
743
+ offs.push(ctx.data.on(type, guard(handler)))
744
+ }
745
+
746
+ on("session.step.started", (evt) => {
747
+ const { sessionID, assistantMessageID, started } = evt.data
748
+ recordMessageStart(tracker, assistantMessageID, started)
749
+ clearSessionLive(tracker, sessionID)
750
+ dirty()
751
+ })
752
+
753
+ const onDelta = (kind: DeltaKind) => (evt: V2Event) => {
754
+ const { sessionID, assistantMessageID, delta } = evt.data
755
+ dirty(recordDelta(tracker, { sessionID, messageID: assistantMessageID, kind, delta, at: Date.now(), serverAt: evt.created }))
756
+ }
757
+ on("session.text.delta", onDelta("text"))
758
+ on("session.reasoning.delta", onDelta("reasoning"))
759
+ on("session.tool.input.delta", onDelta("tool"))
760
+
761
+ on("session.step.ended", (evt) => {
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()
766
+ })
767
+
768
+ on("session.step.failed", (evt) => {
769
+ const { sessionID, assistantMessageID } = evt.data
770
+ discardMessage(tracker, assistantMessageID)
771
+ clearSessionLive(tracker, sessionID)
772
+ dirty()
773
+ })
774
+
775
+ on("session.tool.input.started", (evt) => {
776
+ setThinking(tracker, evt.data.sessionID, false)
777
+ dirty()
778
+ })
779
+
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
+ }
786
+
787
+ const routeSessionID = () => {
788
+ const r = ctx.ui.router.current()
789
+ return r.type === "session" ? r.sessionID : undefined
790
+ }
791
+
792
+ const unregisterSlot = ctx.ui.slot({
793
+ append: core.options.slot,
794
+ render(input) {
795
+ return (
796
+ <MeterDisplay
797
+ core={core}
798
+ palette={v2Palette(ctx.theme)}
799
+ sessionID={() => input.sessionID ?? routeSessionID()}
800
+ requestRender={() => ctx.renderer.requestRender()}
801
+ />
802
+ )
803
+ },
804
+ })
805
+
806
+ return () => {
807
+ for (const off of offs) off()
808
+ if (typeof unregisterSlot === "function") unregisterSlot()
809
+ scheduler.dispose()
810
+ }
811
+ }
812
+
813
+ const plugin: TuiPluginModule & { id: string; setup: typeof setup } = {
814
+ id: "opencode-tps-meter",
815
+ tui,
816
+ setup,
817
+ }
445
818
  export default plugin