Files
oc-ls-stats/tui.tsx
T

268 lines
8.8 KiB
TypeScript

/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, createSignal } from "solid-js"
type StreamSample = {
at: number
tokens: number
}
const STREAM_WINDOW_MS = 5_000
const LIVE_STALE_MS = 1_500
const SINGLE_SAMPLE_MS = 1_000
type MessageTiming = {
sessionID: string
requestStartAt: number
firstTokenAt?: number
lastTokenAt?: number
lastToolCallAt?: number
}
type SessionAverage = {
totalTokens: number
totalDurationMs: number
totalTtftMs: number
messageCount: number
}
type TrackerState = {
streamSamplesBySession: Record<string, StreamSample[]>
messageTimingByID: Record<string, MessageTiming>
sessionAverageByID: Record<string, SessionAverage>
}
function estimateStreamTokens(delta: string) {
return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 5))
}
function formatRate(value: number, label: "TPS" | "AVG") {
if (!Number.isFinite(value) || value <= 0) return undefined
if (value >= 100) return `${Math.round(value)}${label === "TPS" ? " TPS" : ""}`
if (value >= 10) return `${value.toFixed(1)}${label === "TPS" ? " TPS" : ""}`
return `${value.toFixed(2)}${label === "TPS" ? " TPS" : ""}`
}
function formatTtft(value: number) {
if (!Number.isFinite(value) || value < 0) return undefined
return `${value.toFixed(1)}s`
}
function activeDurationMs(samples: StreamSample[], tailAt?: number) {
if (samples.length === 0) return 0
if (samples.length === 1) {
const tailDuration = tailAt ? Math.max(0, tailAt - samples[0].at) : SINGLE_SAMPLE_MS
return Math.min(Math.max(tailDuration, 250), SINGLE_SAMPLE_MS)
}
let duration = 0
for (let i = 1; i < samples.length; i++) {
duration += Math.max(0, samples[i].at - samples[i - 1].at)
}
if (tailAt) {
duration += Math.max(0, tailAt - samples[samples.length - 1].at)
}
return Math.max(duration, SINGLE_SAMPLE_MS)
}
function SessionPromptRight(props: {
api: Parameters<TuiPlugin>[0]
sessionID: string
tracker: TrackerState
version: () => number
clock: () => number
}) {
const sessionAverage = createMemo(() => {
props.version()
const totals = props.tracker.sessionAverageByID[props.sessionID]
if (!totals || totals.totalTokens <= 0 || totals.totalDurationMs <= 0) return undefined
return formatRate(totals.totalTokens / (totals.totalDurationMs / 1000), "AVG")
})
const sessionTtft = createMemo(() => {
props.version()
const totals = props.tracker.sessionAverageByID[props.sessionID]
if (!totals || totals.messageCount <= 0 || totals.totalTtftMs < 0) return undefined
return formatTtft(totals.totalTtftMs / totals.messageCount / 1000)
})
const liveTps = createMemo(() => {
props.version()
props.clock()
const status = props.api.state.session.status(props.sessionID)
if (status?.type === "idle") return undefined
const samples = props.tracker.streamSamplesBySession[props.sessionID] ?? []
if (samples.length === 0) return undefined
const now = Date.now()
const relevant = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
if (relevant.length === 0) return undefined
const lastSample = relevant[relevant.length - 1]
if (!lastSample || now - lastSample.at > LIVE_STALE_MS) return undefined
const total = relevant.reduce((sum, sample) => sum + sample.tokens, 0)
const durationSeconds = activeDurationMs(relevant, now) / 1000
if (durationSeconds <= 0) return undefined
return formatRate(total / durationSeconds, "AVG")
})
const text = createMemo(() => {
const live = liveTps() ?? "-"
const avg = sessionAverage() ?? "-"
const ttft = sessionTtft() ?? "-"
return `TPS ${live} | AVG ${avg} | TTFT ${ttft}`
})
return <>{text() ? <text fg={props.api.theme.current.textMuted}>{text()}</text> : null}</>
}
const tui: TuiPlugin = async (api) => {
const tracker: TrackerState = {
streamSamplesBySession: {},
messageTimingByID: {},
sessionAverageByID: {},
}
const [version, setVersion] = createSignal(0)
const [clock, setClock] = createSignal(Date.now())
const bump = () => setVersion((value) => value + 1)
const pruneSamples = (now = Date.now()) => {
let changed = false
for (const [sessionID, samples] of Object.entries(tracker.streamSamplesBySession)) {
const next = samples.filter((sample) => now - sample.at <= STREAM_WINDOW_MS)
if (next.length !== samples.length) {
changed = true
if (next.length > 0) tracker.streamSamplesBySession[sessionID] = next
else delete tracker.streamSamplesBySession[sessionID]
}
}
if (changed) bump()
}
const clearLiveSamples = (sessionID: string) => {
if (!tracker.streamSamplesBySession[sessionID]?.length) return
delete tracker.streamSamplesBySession[sessionID]
bump()
}
const appendSample = (sessionID: string, messageID: string, sample: StreamSample) => {
const now = sample.at
tracker.streamSamplesBySession[sessionID] = [
...(tracker.streamSamplesBySession[sessionID] ?? []).filter((item) => now - item.at <= STREAM_WINDOW_MS),
sample,
]
const timing = tracker.messageTimingByID[messageID]
if (timing) {
tracker.messageTimingByID[messageID] = timing.firstTokenAt
? { ...timing, lastTokenAt: now }
: { ...timing, firstTokenAt: now, lastTokenAt: now }
}
bump()
}
const onDelta = api.event.on("message.part.delta", (evt) => {
if (evt.properties.field !== "text") return
const parts = api.state.part(evt.properties.messageID)
const part = parts.find((item) => item.id === evt.properties.partID)
if (!part) return
if (part.type !== "text" && part.type !== "reasoning") return
appendSample(evt.properties.sessionID, evt.properties.messageID, {
at: Date.now(),
tokens: estimateStreamTokens(evt.properties.delta),
})
})
const onMessage = api.event.on("message.updated", (evt) => {
if (evt.properties.info.role !== "assistant") return
if (!evt.properties.info.time.completed) {
const existing = tracker.messageTimingByID[evt.properties.info.id]
tracker.messageTimingByID[evt.properties.info.id] = {
sessionID: evt.properties.sessionID,
requestStartAt: evt.properties.info.time.created,
firstTokenAt: existing?.firstTokenAt,
lastTokenAt: existing?.lastTokenAt,
lastToolCallAt: existing?.lastToolCallAt,
}
bump()
return
}
const timing = tracker.messageTimingByID[evt.properties.info.id]
if (timing?.sessionID === evt.properties.sessionID && typeof timing.firstTokenAt === "number") {
const totalTokens = evt.properties.info.tokens.output + evt.properties.info.tokens.reasoning
const endAt =
evt.properties.info.finish === "tool-calls"
? timing.lastToolCallAt
: evt.properties.info.time.completed
const durationMs = typeof endAt === "number" ? Math.max(endAt - timing.firstTokenAt, 1) : undefined
const ttftMs = Math.max(timing.firstTokenAt - timing.requestStartAt, 0)
if (totalTokens > 0 && durationMs) {
const totals = tracker.sessionAverageByID[evt.properties.sessionID] ?? {
totalTokens: 0,
totalDurationMs: 0,
totalTtftMs: 0,
messageCount: 0,
}
tracker.sessionAverageByID[evt.properties.sessionID] = {
totalTokens: totals.totalTokens + totalTokens,
totalDurationMs: totals.totalDurationMs + durationMs,
totalTtftMs: totals.totalTtftMs + ttftMs,
messageCount: totals.messageCount + 1,
}
}
}
delete tracker.messageTimingByID[evt.properties.info.id]
pruneSamples(evt.properties.info.time.completed)
bump()
})
const onPart = api.event.on("message.part.updated", (evt) => {
if (evt.properties.part.type !== "tool") return
if (
evt.properties.part.state.status === "running" ||
evt.properties.part.state.status === "completed" ||
evt.properties.part.state.status === "error"
) {
clearLiveSamples(evt.properties.sessionID)
}
if (evt.properties.part.state.status !== "running") return
const timing = tracker.messageTimingByID[evt.properties.part.messageID]
if (!timing) return
tracker.messageTimingByID[evt.properties.part.messageID] = {
...timing,
lastToolCallAt: evt.properties.part.state.time.start,
}
bump()
})
const timer = setInterval(() => {
setClock(Date.now())
pruneSamples()
}, 1000)
api.lifecycle.onDispose(() => {
onDelta()
onMessage()
onPart()
clearInterval(timer)
})
api.slots.register({
slots: {
session_prompt_right(_ctx, value) {
return <SessionPromptRight api={api} sessionID={value.session_id} tracker={tracker} version={version} clock={clock} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = {
id: "oc-tps",
tui,
}
export default plugin