@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/LICENSE +21 -0
- package/README.md +18 -11
- package/README.zh-CN.md +18 -11
- package/package.json +4 -2
- package/tui.tsx +645 -429
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,71 +84,24 @@ function formatTtft(valueMs: number): string | undefined {
|
|
|
106
84
|
}
|
|
107
85
|
|
|
108
86
|
type SpeedTier = "slow" | "normal" | "fast" | "faster"
|
|
109
|
-
type
|
|
110
|
-
|
|
111
|
-
type
|
|
112
|
-
|
|
113
|
-
|
|
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,
|
|
144
|
-
if (tps <
|
|
145
|
-
if (tps <
|
|
146
|
-
if (tps <
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
if (ms <
|
|
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
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
tracker
|
|
220
|
-
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
let
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
452
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
bump()
|
|
459
|
-
})
|
|
628
|
+
unsubscribe = core.scheduler.addView(flush)
|
|
629
|
+
onCleanup(() => unsubscribe())
|
|
460
630
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
-
|
|
498
|
-
sessionID={value.session_id}
|
|
499
|
-
tracker={tracker}
|
|
500
|
-
config={config}
|
|
723
|
+
core={core}
|
|
501
724
|
palette={v1Palette(api.theme.current)}
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
513
|
-
const
|
|
514
|
-
const
|
|
737
|
+
const core = createMeterCore(ctx.options)
|
|
738
|
+
const { tracker, scheduler } = core
|
|
739
|
+
const dirty = (urgent = false) => scheduler.markDirty(urgent)
|
|
515
740
|
|
|
516
|
-
const
|
|
741
|
+
const offs: (() => void)[] = []
|
|
517
742
|
const on = (type: string, handler: (event: V2Event) => void) => {
|
|
518
|
-
|
|
743
|
+
offs.push(ctx.data.on(type, guard(handler)))
|
|
519
744
|
}
|
|
520
745
|
|
|
521
746
|
on("session.step.started", (evt) => {
|
|
522
|
-
|
|
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 = (
|
|
753
|
+
const onDelta = (kind: DeltaKind) => (evt: V2Event) => {
|
|
526
754
|
const { sessionID, assistantMessageID, delta } = evt.data
|
|
527
|
-
recordDelta(tracker, sessionID, assistantMessageID, evt.created
|
|
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(
|
|
532
|
-
on("session.reasoning.delta", onDelta(
|
|
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(
|
|
537
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
-
|
|
552
|
-
|
|
776
|
+
setThinking(tracker, evt.data.sessionID, false)
|
|
777
|
+
dirty()
|
|
553
778
|
})
|
|
554
779
|
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
567
|
-
|
|
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:
|
|
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
|
-
|
|
579
|
-
sessionID={sessionID}
|
|
580
|
-
tracker={tracker}
|
|
581
|
-
config={config}
|
|
797
|
+
core={core}
|
|
582
798
|
palette={v2Palette(ctx.theme)}
|
|
583
|
-
|
|
584
|
-
|
|
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
|
|
592
|
-
|
|
593
|
-
|
|
807
|
+
for (const off of offs) off()
|
|
808
|
+
if (typeof unregisterSlot === "function") unregisterSlot()
|
|
809
|
+
scheduler.dispose()
|
|
594
810
|
}
|
|
595
811
|
}
|
|
596
812
|
|