commit 21da2b4b5e2d526623a66167ce4e823e6447f95e Author: Daniel Smolsky Date: Sat Apr 4 02:58:46 2026 -0400 Add oc-tps plugin diff --git a/package.json b/package.json new file mode 100644 index 0000000..24f8f3b --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "oc-tps", + "version": "0.0.1", + "type": "module", + "exports": { + "./tui": { + "import": "./tui.tsx" + } + }, + "engines": { + "opencode": ">=1.3.14" + }, + "peerDependencies": { + "@opencode-ai/plugin": "*", + "@opentui/core": "*", + "@opentui/solid": "*", + "solid-js": "*" + } +} diff --git a/tui.tsx b/tui.tsx new file mode 100644 index 0000000..3813299 --- /dev/null +++ b/tui.tsx @@ -0,0 +1,185 @@ +/** @jsxImportSource @opentui/solid */ +import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" +import type { AssistantMessage } from "@opencode-ai/sdk/v2" +import { createMemo, createSignal } from "solid-js" + +type StreamSample = { + at: number + tokens: number +} + +const STREAM_WINDOW_MS = 15_000 +const ACTIVE_GAP_MS = 1_250 +const LIVE_STALE_MS = 1_500 +const SINGLE_SAMPLE_MS = 1_000 +const MAX_MESSAGE_SAMPLES = 4_096 + +type TrackerState = { + streamSamplesBySession: Record + messageSamples: Record +} + +function estimateStreamTokens(delta: string) { + return Math.max(1, Math.ceil(Buffer.byteLength(delta, "utf8") / 4)) +} + +function formatTps(value: number) { + if (!Number.isFinite(value) || value <= 0) return undefined + if (value >= 100) return `${Math.round(value)} TPS` + if (value >= 10) return `${value.toFixed(1)} TPS` + return `${value.toFixed(2)} TPS` +} + +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.min(Math.max(0, samples[i].at - samples[i - 1].at), ACTIVE_GAP_MS) + } + + if (tailAt) { + duration += Math.min(Math.max(0, tailAt - samples[samples.length - 1].at), ACTIVE_GAP_MS) + } + + return Math.max(duration, SINGLE_SAMPLE_MS) +} + +function SessionPromptRight(props: { + api: Parameters[0] + sessionID: string + tracker: TrackerState + version: () => number + clock: () => number +}) { + const sessionMessages = createMemo(() => props.api.state.session.messages(props.sessionID)) + + const finalTps = createMemo(() => { + props.version() + const last = sessionMessages().findLast( + (item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0, + ) + if (!last) return undefined + + const user = sessionMessages().find((item) => item.role === "user" && item.id === last.parentID) + const start = user?.time.created ?? last.time.created + const end = last.time.completed ?? last.time.created + const sampleWindow = props.tracker.messageSamples[last.id] ?? [] + const activeMs = activeDurationMs(sampleWindow) + const wallMs = end > start ? end - start : 0 + const durationSeconds = (activeMs > 0 ? activeMs : wallMs) / 1000 + if (durationSeconds <= 0) return undefined + return formatTps(last.tokens.output / durationSeconds) + }) + + 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 formatTps(total / durationSeconds) + }) + + const text = createMemo(() => { + const live = liveTps() + if (live) return `~${live}` + return finalTps() + }) + + return <>{text() ? {text()} : null} +} + +const tui: TuiPlugin = async (api) => { + const tracker: TrackerState = { + streamSamplesBySession: {}, + messageSamples: {}, + } + 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 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, + ] + tracker.messageSamples[messageID] = [...(tracker.messageSamples[messageID] ?? []), sample].slice(-MAX_MESSAGE_SAMPLES) + 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) return + pruneSamples(evt.properties.info.time.completed) + bump() + }) + + const timer = setInterval(() => { + setClock(Date.now()) + pruneSamples() + }, 1000) + + api.lifecycle.onDispose(() => { + onDelta() + onMessage() + clearInterval(timer) + }) + + api.slots.register({ + slots: { + session_prompt_right(_ctx, value) { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { + id: "oc-tps", + tui, +} + +export default plugin