@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/LICENSE +21 -0
- package/README.md +61 -16
- package/README.zh-CN.md +63 -18
- package/package.json +7 -2
- package/tui.tsx +692 -319
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
|
-
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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(
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
if (ms <
|
|
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
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
480
|
+
const tick = () => {
|
|
481
|
+
const live = prune(tracker, Date.now())
|
|
482
|
+
updateSpinner(true)
|
|
483
|
+
flush()
|
|
484
|
+
reschedule(live)
|
|
317
485
|
}
|
|
318
486
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
567
|
+
type MeterCore = {
|
|
568
|
+
tracker: TrackerState
|
|
569
|
+
options: ResolvedOptions
|
|
570
|
+
spinner: Spinner
|
|
571
|
+
scheduler: Scheduler
|
|
572
|
+
gradient: RGBA[]
|
|
573
|
+
}
|
|
371
574
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
395
|
-
clearSessionLive(evt.properties.sessionID)
|
|
396
|
-
bump()
|
|
397
|
-
})
|
|
599
|
+
// ---- View: one <text>, StyledText content, imperative + diffed (AGENTS.md gotchas #2/#3) ----
|
|
398
600
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|