diff --git a/package.json b/package.json index 1ff91e1..fd7eab6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@troed/oc-ls-stats", - "version": "1.3.1", + "version": "1.4.0", "type": "module", "files": [ "tui.tsx", diff --git a/tui.tsx b/tui.tsx index bafb046..aedb1e7 100644 --- a/tui.tsx +++ b/tui.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource @opentui/solid */ import type { TextRenderable } from "@opentui/core" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" -import { onCleanup } from "solid-js" +import { onCleanup, createSignal } from "solid-js" import { backoffDelayMs, processPoll, resolveServerUrls, selectSessionUrls } from "./src/stats.ts" type StreamSample = { @@ -333,9 +333,274 @@ const tui: TuiPlugin = async (api, options) => { }) } -const plugin: TuiPluginModule & { id: string } = { +// --------------------------------------------------------------------------- +// V2 (opencode >= 2.0) implementation +// +// V2 TUI plugin system replaces the V1 tui(api) function with +// setup(context): the readout registers through context.ui.slot on +// prompt.footer.status (the V2 successor of session_prompt_right), events +// arrive through context.data.on, and provider/session state comes from +// context.data.location / context.data.session. The V1 tui() above is kept +// untouched; both implementations are exposed from the same default export +// (V1 calls tui, V2 calls setup) and share the tracker helpers from +// src/stats.ts. +// --------------------------------------------------------------------------- + +const v2Setup = async (context: any) => { + const location = context.location ?? context.data.location.default() + const themeMuted = context.theme?.text?.muted + + const tracker: TrackerState = { + streamSamplesBySession: {}, + messageTimingByID: {}, + lastPrefillRate: 0, + lastGeneratedTps: 0, + isPrefilling: false, + prefillSlotId: null, + prefillCapturedTokens: null, + prefillStartAt: 0, + isGenerating: false, + generateSlotId: null, + generatePrevNd: 0, + generateStartAt: 0, + prevNdBySlot: {}, + prevPromptTokensBySlot: {}, + failure: null, + } + + // part id -> part type, learned from message.part.updated; used to skip + // non-text/reasoning deltas (e.g. tool output) like the V1 part lookup + const partTypeByID = new Map() + + function getStatusText() { + const s = tracker + if (s.failure) { + return s.failure + } + const pp = s.isPrefilling && s.lastPrefillRate > 0 ? formatPps(s.lastPrefillRate) : undefined + const tps = s.isGenerating && s.lastGeneratedTps > 0 ? formatTps(s.lastGeneratedTps) : undefined + let result = "- tps (TG)" + if (s.isPrefilling && pp) { + result = `${pp} tps (PP)` + } else if (s.isGenerating && tps) { + result = `${tps} tps (TG)` + } + return result + } + + const [statusText, setStatusText] = createSignal("- tps (TG)") + + const bump = () => { + setStatusText(getStatusText()) + } + + 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, + firstResponseAt: timing.firstResponseAt ?? now, + firstTokenAt: now, + lastTokenAt: now, + } + } + bump() + } + + let llamaServerUrls: string[] = [] + let providerList: unknown[] = [] + const backoffByUrl = new Map() + + const currentSessionID = (): string | undefined => { + const route = context.ui.router.current?.() + if (route?.sessionID) return route.sessionID + const loc = context.location ?? context.data.location.default() + return loc?.sessionID + } + + const pollMetrics = async () => { + if (!llamaServerUrls.length) return + + let model: string | undefined + let sessionProviderId: string | undefined + const routeSessionID = currentSessionID() + if (routeSessionID) { + const session = context.data.session.get(routeSessionID) + if (session?.model?.id) { + model = session.model.id + } + sessionProviderId = session?.model?.providerID + } + + const activeUrls = selectSessionUrls(llamaServerUrls, providerList, sessionProviderId) + if (!activeUrls.length) { + tracker.failure = "n/a" + bump() + return + } + for (const baseUrl of activeUrls) { + const now = Date.now() + const backoff = backoffByUrl.get(baseUrl) + if (backoff && now < backoff.nextRetryAt) { + tracker.failure = "n/a" + bump() + continue + } + + const slots = await fetchSlots(baseUrl, model) + if (slots == null) { + const failures = (backoff?.failures ?? 0) + 1 + const delay = backoffDelayMs(failures, BACKOFF_BASE_MS, BACKOFF_MAX_MS) + const jitter = Math.floor(Math.random() * (delay / 4)) + backoffByUrl.set(baseUrl, { + failures, + nextRetryAt: Date.now() + Math.min(delay + jitter, BACKOFF_MAX_MS), + }) + tracker.failure = "n/a" + bump() + continue + } + + backoffByUrl.delete(baseUrl) + const slotList = Array.isArray(slots) ? slots : Object.values(slots) + if (slotList.length === 0) { + tracker.failure = "n/a" + bump() + continue + } + tracker.failure = null + bump() + + processPoll(slotList, tracker) + bump() + } + } + + const propsOf = (evt: any) => evt?.data ?? evt?.properties ?? {} + + const offDelta = context.data.on("message.part.delta", (evt: any) => { + const p = propsOf(evt) + if (p.field !== "text") return + const partType = partTypeByID.get(p.partID) + if (partType && partType !== "text" && partType !== "reasoning") return + appendSample(p.sessionID, p.messageID, { + at: Date.now(), + tokens: estimateStreamTokens(String(p.delta ?? "")), + }) + }) + + const offMessage = context.data.on("message.updated", (evt: any) => { + const p = propsOf(evt) + if (p.info?.role !== "assistant") return + + if (!p.info?.time?.completed) { + const existing = tracker.messageTimingByID[p.info.id] + tracker.messageTimingByID[p.info.id] = { + sessionID: p.sessionID, + requestStartAt: p.info.time.created, + firstResponseAt: existing?.firstResponseAt, + firstTokenAt: existing?.firstTokenAt, + lastTokenAt: existing?.lastTokenAt, + } + bump() + return + } + + delete tracker.messageTimingByID[p.info.id] + pruneSamples(p.info?.time?.completed) + bump() + }) + + const offPart = context.data.on("message.part.updated", (evt: any) => { + const p = propsOf(evt) + const part = p.part + if (!part?.id) return + if (typeof part.type === "string") partTypeByID.set(part.id, part.type) + if (part.type !== "tool") return + if ( + part.state?.status === "running" || + part.state?.status === "completed" || + part.state?.status === "error" + ) { + clearLiveSamples(p.sessionID) + } + const messageID = part.messageID + if (!messageID) return + const timing = tracker.messageTimingByID[messageID] + if (!timing) return + if (part.state?.status === "pending") { + tracker.messageTimingByID[messageID] = { + ...timing, + firstResponseAt: timing.firstResponseAt ?? p.time, + } + bump() + return + } + if (part.state?.status === "running") bump() + }) + + const timer = setInterval(() => { + pruneSamples() + }, 1000) + + const slotTimer = setInterval(() => { + void pollMetrics() + }, SLOT_POLL_MS) + + try { + await context.data.location.provider.sync(location) + providerList = context.data.location.provider.list(location) ?? [] + llamaServerUrls = resolveServerUrls(context.options, providerList, undefined) + } catch (e) { + console.error("[oc-ls-stats] resolveServerUrls error:", e) + llamaServerUrls = ["http://localhost:8080"] + } + + const unregisterSlot = context.ui.slot({ + append: "prompt.footer.status", + render: () => {statusText()}, + }) + + return () => { + offDelta() + offMessage() + offPart() + clearInterval(timer) + clearInterval(slotTimer) + if (typeof unregisterSlot === "function") unregisterSlot() + } +} + +const plugin = { id: "@troed/oc-ls-stats", tui, + setup: v2Setup, } export default plugin