From 4adbb19adb93bb124edf6ddfc2e43f59277254f3 Mon Sep 17 00:00:00 2001 From: cosmiclasagnadev Date: Fri, 17 Apr 2026 21:59:33 +0800 Subject: [PATCH] refactor: tps usage algo --- README.md | 4 +- package.json | 2 +- src/agg.test.ts | 135 ++++++++++++---- src/agg.ts | 185 ++++++++++++---------- src/reconcile.test.ts | 216 +++++++++++++++++--------- src/reconcile.ts | 184 ++++++++++++---------- src/view.tsx | 353 +++++++++++++++++++++++++----------------- 7 files changed, 661 insertions(+), 418 deletions(-) diff --git a/README.md b/README.md index dc4fd42..0980545 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ If you keep plugin config in `tui.json`, that works too: The throughput section shows end-to-end output rate, not decode-only model TPS. -It is calculated as output tokens divided by total assistant message duration. +It is calculated as output tokens divided by the time between the first visible assistant text and message completion. ## Current Limits @@ -57,4 +57,4 @@ bun run build ## Release -The standalone repo is intended to publish through GitHub Actions with npm trusted publishing. \ No newline at end of file +The standalone repo is intended to publish through GitHub Actions with npm trusted publishing. diff --git a/package.json b/package.json index 3f724c4..a487722 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-usage-dashboard", - "version": "1.0.2", + "version": "1.0.3", "description": "TUI usage dashboard plugin for OpenCode", "type": "module", "license": "MIT", diff --git a/src/agg.test.ts b/src/agg.test.ts index 36a9fa1..a0098d9 100644 --- a/src/agg.test.ts +++ b/src/agg.test.ts @@ -1,8 +1,8 @@ -import assert from "node:assert/strict" -import { describe, it } from "node:test" -import type { Message, Part } from "@opencode-ai/sdk/v2" -import { dayKey, putMsg, putTool } from "./agg" -import { createAgg } from "./state" +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { Message, Part } from "@opencode-ai/sdk/v2"; +import { dayKey, putMsg, putSpeed, putTool } from "./agg"; +import { createAgg } from "./state"; function message(overrides: Partial = {}) { return { @@ -25,7 +25,7 @@ function message(overrides: Partial = {}) { completed: Date.parse("2026-01-02T10:00:05.000Z"), }, ...overrides, - } as Message + } as Message; } function tool(status: "running" | "completed" | "error"): Part { @@ -36,7 +36,7 @@ function tool(status: "running" | "completed" | "error"): Part { sessionID: "session-1", tool: "bash", state: { status: "running" }, - } as Part + } as Part; } return { id: "tool-1", @@ -50,38 +50,113 @@ function tool(status: "running" | "completed" | "error"): Part { end: Date.parse("2026-01-02T10:00:02.000Z"), }, }, - } as Part + } as Part; +} + +function text( + start: number, + overrides: Partial> = {}, +): Part { + return { + id: `text-${start}`, + type: "text", + sessionID: "session-1", + messageID: "msg-1", + text: "hello", + time: { start }, + ...overrides, + } as Part; } describe("agg", () => { it("counts a completed assistant message once", () => { - const agg = createAgg() - const msg = message() - const completed = (msg.time as { completed: number }).completed + const agg = createAgg(); + const msg = message(); + const completed = (msg.time as { completed: number }).completed; + + assert.equal(putMsg(agg, msg), true); + + const day = agg.by_s[msg.sessionID]![dayKey(completed)]!; + assert.equal(day.totals.msg, 1); + assert.equal(day.totals.cost, 1.25); + assert.equal(day.models["provider/model"]!.output, 20); + assert.equal(day.agents.build!.n, 1); + }); + + it("uses first visible text start for throughput", () => { + const agg = createAgg(); + const msg = message({ + time: { + created: Date.parse("2026-01-02T10:00:00.000Z"), + completed: Date.parse("2026-01-02T10:00:05.000Z"), + }, + }); + const completed = (msg.time as { completed: number }).completed; + + assert.equal( + putSpeed(agg, msg, [ + text(Date.parse("2026-01-02T10:00:03.000Z")), + text(Date.parse("2026-01-02T10:00:04.000Z")), + ]), + true, + ); + + const day = agg.by_s[msg.sessionID]![dayKey(completed)]!; + assert.equal(day.speed["provider/model"]!.out, 20); + assert.equal(day.speed["provider/model"]!.ms, 2000); + assert.equal(day.speed["provider/model"]!.n, 1); + }); + + it("ignores synthetic and ignored text when computing throughput", () => { + const agg = createAgg(); + const msg = message({ + time: { + created: Date.parse("2026-01-02T10:00:00.000Z"), + completed: Date.parse("2026-01-02T10:00:05.000Z"), + }, + }); + const completed = (msg.time as { completed: number }).completed; + + assert.equal( + putSpeed(agg, msg, [ + text(Date.parse("2026-01-02T10:00:01.000Z"), { synthetic: true }), + text(Date.parse("2026-01-02T10:00:02.000Z"), { ignored: true }), + text(Date.parse("2026-01-02T10:00:04.000Z")), + ]), + true, + ); + + const day = agg.by_s[msg.sessionID]![dayKey(completed)]!; + assert.equal(day.speed["provider/model"]!.ms, 1000); + }); - assert.equal(putMsg(agg, msg), true) + it("skips throughput when there is no visible text start", () => { + const agg = createAgg(); + const msg = message(); - const day = agg.by_s[msg.sessionID]![dayKey(completed)]! - assert.equal(day.totals.msg, 1) - assert.equal(day.totals.cost, 1.25) - assert.equal(day.models["provider/model"]!.output, 20) - assert.equal(day.agents.build!.n, 1) - }) + assert.equal( + putSpeed(agg, msg, [ + text(Date.parse("2026-01-02T10:00:01.000Z"), { synthetic: true }), + ]), + false, + ); + assert.equal(agg.by_s[msg.sessionID], undefined); + }); it("ignores non-terminal tool updates", () => { - const agg = createAgg() + const agg = createAgg(); const completed = tool("completed") as Part & { - state: { status: "completed"; time: { start: number; end: number } } - } + state: { status: "completed"; time: { start: number; end: number } }; + }; - assert.equal(putTool(agg, tool("running")), false) - assert.equal(agg.by_s["session-1"], undefined) + assert.equal(putTool(agg, tool("running")), false); + assert.equal(agg.by_s["session-1"], undefined); - assert.equal(putTool(agg, completed), true) + assert.equal(putTool(agg, completed), true); - const day = agg.by_s["session-1"]![dayKey(completed.state.time.end)]! - assert.equal(day.totals.tool, 1) - assert.equal(day.tools.bash!.n, 1) - assert.equal(day.tools.bash!.err, 0) - }) -}) + const day = agg.by_s["session-1"]![dayKey(completed.state.time.end)]!; + assert.equal(day.totals.tool, 1); + assert.equal(day.tools.bash!.n, 1); + assert.equal(day.tools.bash!.err, 0); + }); +}); diff --git a/src/agg.ts b/src/agg.ts index d7d1be1..adf01cc 100644 --- a/src/agg.ts +++ b/src/agg.ts @@ -1,54 +1,60 @@ -import type { AssistantMessage, Message, Part, Session, ToolPart } from "@opencode-ai/sdk/v2" -import { store } from "./state" -import type { Agg } from "./types" -import type { SessDay, Win } from "./types" +import type { + AssistantMessage, + Message, + Part, + Session, + ToolPart, +} from "@opencode-ai/sdk/v2"; +import { store } from "./state"; +import type { Agg } from "./types"; +import type { SessDay, Win } from "./types"; export function dayKey(ts: number) { - return new Date(ts).toISOString().slice(0, 10) + return new Date(ts).toISOString().slice(0, 10); } export function dayRange(start: number) { - if (start <= 0) return store.agg.days - const days: string[] = [] - const d = new Date(start) - const end = new Date() + if (start <= 0) return store.agg.days; + const days: string[] = []; + const d = new Date(start); + const end = new Date(); while (d <= end) { - days.push(d.toISOString().slice(0, 10)) - d.setDate(d.getDate() + 1) + days.push(d.toISOString().slice(0, 10)); + d.setDate(d.getDate() + 1); } - return days + return days; } export function since(win: Win) { - if (win === "all") return 0 - if (win === "30d") return Date.now() - 30 * 24 * 60 * 60 * 1000 - return Date.now() - 7 * 24 * 60 * 60 * 1000 + if (win === "all") return 0; + if (win === "30d") return Date.now() - 30 * 24 * 60 * 60 * 1000; + return Date.now() - 7 * 24 * 60 * 60 * 1000; } export function noteOfError(err: unknown) { - if (!err || typeof err !== "object") return "error" - if ("name" in err && typeof err.name === "string") return err.name - if ("type" in err && typeof err.type === "string") return err.type - return "error" + if (!err || typeof err !== "object") return "error"; + if ("name" in err && typeof err.name === "string") return err.name; + if ("type" in err && typeof err.type === "string") return err.type; + return "error"; } export function isAssistant(msg: Message): msg is AssistantMessage { - return msg.role === "assistant" + return msg.role === "assistant"; } export function isTool(part: Part): part is ToolPart { - return part.type === "tool" + return part.type === "tool"; } export function sessDay(agg: Agg, sid: string, dk: string): SessDay { - let sd = agg.by_s[sid] + let sd = agg.by_s[sid]; if (!sd) { - sd = {} - agg.by_s[sid] = sd + sd = {}; + agg.by_s[sid] = sd; } - let d = sd[dk] + let d = sd[dk]; if (!d) { - if (!agg.days.includes(dk)) agg.days.push(dk) + if (!agg.days.includes(dk)) agg.days.push(dk); d = { models: {}, tools: {}, @@ -56,77 +62,98 @@ export function sessDay(agg: Agg, sid: string, dk: string): SessDay { errors: {}, speed: {}, totals: { msg: 0, tool: 0, cost: 0, cache: 0, input: 0 }, - } - sd[dk] = d + }; + sd[dk] = d; } - return d + return d; } export function putSess(agg: Agg, s: Session) { - agg.meta[s.id] = { id: s.id, pid: s.projectID, dir: s.directory } - const fresh = agg.fresh[s.id] ?? { updated: 0, synced: 0 } - fresh.updated = Math.max(fresh.updated, s.time.updated) - agg.fresh[s.id] = fresh + agg.meta[s.id] = { id: s.id, pid: s.projectID, dir: s.directory }; + const fresh = agg.fresh[s.id] ?? { updated: 0, synced: 0 }; + fresh.updated = Math.max(fresh.updated, s.time.updated); + agg.fresh[s.id] = fresh; } export function putMsg(agg: Agg, msg: Message) { - if (!isAssistant(msg)) return false - if (!msg.time.completed) return false - const dk = dayKey(msg.time.completed ?? msg.time.created) - const b = sessDay(agg, msg.sessionID, dk) - const mid = `${msg.providerID}/${msg.modelID}` - const aid = msg.agent - if (!b.models[mid]) b.models[mid] = { n: 0, cost: 0, input: 0, output: 0 } - b.models[mid]!.n += 1 - b.models[mid]!.cost += msg.cost - b.models[mid]!.input += msg.tokens.input - b.models[mid]!.output += msg.tokens.output - if (!b.agents[aid]) b.agents[aid] = { n: 0, cost: 0, output: 0 } - b.agents[aid]!.n += 1 - b.agents[aid]!.cost += msg.cost - b.agents[aid]!.output += msg.tokens.output - if (msg.time.completed > msg.time.created) { - if (!b.speed[mid]) b.speed[mid] = { out: 0, ms: 0, n: 0 } - b.speed[mid]!.out += msg.tokens.output - b.speed[mid]!.ms += msg.time.completed - msg.time.created - b.speed[mid]!.n += 1 - } + if (!isAssistant(msg)) return false; + if (!msg.time.completed) return false; + const dk = dayKey(msg.time.completed ?? msg.time.created); + const b = sessDay(agg, msg.sessionID, dk); + const mid = `${msg.providerID}/${msg.modelID}`; + const aid = msg.agent; + if (!b.models[mid]) b.models[mid] = { n: 0, cost: 0, input: 0, output: 0 }; + b.models[mid]!.n += 1; + b.models[mid]!.cost += msg.cost; + b.models[mid]!.input += msg.tokens.input; + b.models[mid]!.output += msg.tokens.output; + if (!b.agents[aid]) b.agents[aid] = { n: 0, cost: 0, output: 0 }; + b.agents[aid]!.n += 1; + b.agents[aid]!.cost += msg.cost; + b.agents[aid]!.output += msg.tokens.output; if (msg.error) { - const key = `assistant:${noteOfError(msg.error)}` - b.errors[key] = (b.errors[key] ?? 0) + 1 + const key = `assistant:${noteOfError(msg.error)}`; + b.errors[key] = (b.errors[key] ?? 0) + 1; } - b.totals.msg += 1 - b.totals.cost += msg.cost - b.totals.input += msg.tokens.input + msg.tokens.cache.read - b.totals.cache += msg.tokens.cache.read - return true + b.totals.msg += 1; + b.totals.cost += msg.cost; + b.totals.input += msg.tokens.input + msg.tokens.cache.read; + b.totals.cache += msg.tokens.cache.read; + return true; +} + +export function putSpeed(agg: Agg, msg: Message, parts: Part[]) { + if (!isAssistant(msg)) return false; + if (!msg.time.completed || msg.tokens.output <= 0) return false; + const start = parts.reduce((start, part) => { + if ( + part.type !== "text" || + part.synthetic === true || + part.ignored === true || + !part.time?.start + ) + return start; + if (start == null) return part.time.start; + return Math.min(start, part.time.start); + }, undefined); + if (start == null) return false; + const ms = msg.time.completed - start; + if (ms <= 0) return false; + const dk = dayKey(msg.time.completed); + const b = sessDay(agg, msg.sessionID, dk); + const mid = `${msg.providerID}/${msg.modelID}`; + if (!b.speed[mid]) b.speed[mid] = { out: 0, ms: 0, n: 0 }; + b.speed[mid]!.out += msg.tokens.output; + b.speed[mid]!.ms += ms; + b.speed[mid]!.n += 1; + return true; } export function putTool(agg: Agg, part: Part) { - if (!isTool(part)) return false - const s = part.state - if (s.status !== "completed" && s.status !== "error") return false - if (!("time" in s) || !s.time || !("end" in s.time)) return false - const dk = dayKey(s.time.end) - const b = sessDay(agg, part.sessionID, dk) - if (!b.tools[part.tool]) b.tools[part.tool] = { n: 0, err: 0, ms: 0 } - const t = b.tools[part.tool]! + if (!isTool(part)) return false; + const s = part.state; + if (s.status !== "completed" && s.status !== "error") return false; + if (!("time" in s) || !s.time || !("end" in s.time)) return false; + const dk = dayKey(s.time.end); + const b = sessDay(agg, part.sessionID, dk); + if (!b.tools[part.tool]) b.tools[part.tool] = { n: 0, err: 0, ms: 0 }; + const t = b.tools[part.tool]!; if (s.status === "completed") { - t.n += 1 - t.ms += s.time.end - s.time.start + t.n += 1; + t.ms += s.time.end - s.time.start; } else if (s.status === "error") { - t.n += 1 - t.err += 1 - t.ms += s.time.end - s.time.start + t.n += 1; + t.err += 1; + t.ms += s.time.end - s.time.start; } - b.totals.tool += 1 - return true + b.totals.tool += 1; + return true; } export function rebuildDays() { - const days = new Set() + const days = new Set(); for (const sd of Object.values(store.agg.by_s)) { - for (const dk of Object.keys(sd)) days.add(dk) + for (const dk of Object.keys(sd)) days.add(dk); } - store.agg.days = [...days].sort() + store.agg.days = [...days].sort(); } diff --git a/src/reconcile.test.ts b/src/reconcile.test.ts index c7f418f..4da12be 100644 --- a/src/reconcile.test.ts +++ b/src/reconcile.test.ts @@ -1,9 +1,9 @@ -import assert from "node:assert/strict" -import { afterEach, describe, it } from "node:test" -import type { Message, Part, Session } from "@opencode-ai/sdk/v2" -import { dayKey, putMsg, putSess } from "./agg" -import { reconcileSession, seed } from "./reconcile" -import { createAgg, resetState, store } from "./state" +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; +import type { Message, Part, Session } from "@opencode-ai/sdk/v2"; +import { dayKey, putMsg, putSess } from "./agg"; +import { reconcileSession, seed } from "./reconcile"; +import { createAgg, resetState, store } from "./state"; function session(updated: number): Session { return { @@ -13,7 +13,7 @@ function session(updated: number): Session { time: { updated, }, - } as Session + } as Session; } function message(id: string, completed: number, output: number): Message { @@ -36,24 +36,48 @@ function message(id: string, completed: number, output: number): Message { created: completed - 1000, completed, }, - } as Message + } as Message; } -function sessionMessage(sessionID: string, id: string, completed: number, output: number): Message { +function sessionMessage( + sessionID: string, + id: string, + completed: number, + output: number, +): Message { return { ...message(id, completed, output), sessionID, - } as Message + } as Message; } -function api(rows: { info: Message; parts: Part[] }[] | Error, current: Session) { +function textPart( + messageID: string, + start: number, + overrides: Partial> = {}, +): Part { + return { + id: `text-${messageID}-${start}`, + type: "text", + sessionID: "session-1", + messageID, + text: "hello", + time: { start }, + ...overrides, + } as Part; +} + +function api( + rows: { info: Message; parts: Part[] }[] | Error, + current: Session, +) { return { client: { session: { get: async () => ({ data: current }), messages: async () => { - if (rows instanceof Error) throw rows - return { data: rows } + if (rows instanceof Error) throw rows; + return { data: rows }; }, }, }, @@ -67,7 +91,7 @@ function api(rows: { info: Message; parts: Part[] }[] | Error, current: Session) directory: "/tmp/project", }, }, - } as any + } as any; } function seedApi( @@ -80,9 +104,9 @@ function seedApi( session: { list: async () => ({ data: list }), messages: async ({ sessionID }: { sessionID: string }) => { - const rows = rowsBySession[sessionID] - if (rows instanceof Error) throw rows - return { data: rows ?? [] } + const rows = rowsBySession[sessionID]; + if (rows instanceof Error) throw rows; + return { data: rows ?? [] }; }, }, }, @@ -96,92 +120,130 @@ function seedApi( directory: "/tmp/project", }, }, - } as any + } as any; } afterEach(() => { - resetState() -}) + resetState(); +}); describe("reconcileSession", () => { it("replaces old session usage instead of stacking it", async () => { - const original = session(Date.parse("2026-01-01T00:00:00.000Z")) - putSess(store.agg, original) - putMsg(store.agg, message("old", Date.parse("2026-01-01T12:00:00.000Z"), 5)) - - const current = session(Date.parse("2026-01-02T00:00:00.000Z")) - const nextMessage = message("new", Date.parse("2026-01-02T12:00:00.000Z"), 20) - const completed = (nextMessage.time as { completed: number }).completed - - await reconcileSession(api([{ info: nextMessage, parts: [] }], current), current.id) - - const bucket = store.agg.by_s[current.id]! - assert.deepEqual(Object.keys(bucket), [dayKey(completed)]) - assert.equal(bucket[dayKey(completed)]!.totals.msg, 1) - assert.equal(bucket[dayKey(completed)]!.models["provider/model"]!.output, 20) - }) + const original = session(Date.parse("2026-01-01T00:00:00.000Z")); + putSess(store.agg, original); + putMsg( + store.agg, + message("old", Date.parse("2026-01-01T12:00:00.000Z"), 5), + ); + + const current = session(Date.parse("2026-01-02T00:00:00.000Z")); + const nextMessage = message( + "new", + Date.parse("2026-01-02T12:00:00.000Z"), + 20, + ); + const completed = (nextMessage.time as { completed: number }).completed; + const firstText = completed - 250; + + await reconcileSession( + api( + [{ info: nextMessage, parts: [textPart(nextMessage.id, firstText)] }], + current, + ), + current.id, + ); + + const bucket = store.agg.by_s[current.id]!; + assert.deepEqual(Object.keys(bucket), [dayKey(completed)]); + assert.equal(bucket[dayKey(completed)]!.totals.msg, 1); + assert.equal( + bucket[dayKey(completed)]!.models["provider/model"]!.output, + 20, + ); + assert.equal(bucket[dayKey(completed)]!.speed["provider/model"]!.ms, 250); + }); it("preserves cached usage when message refresh fails", async () => { - const original = session(Date.parse("2026-01-01T00:00:00.000Z")) - const cachedMessage = message("cached", Date.parse("2026-01-01T12:00:00.000Z"), 5) - putSess(store.agg, original) - putMsg(store.agg, cachedMessage) - store.agg.fresh[original.id] = { updated: original.time.updated, synced: original.time.updated } - const before = JSON.parse(JSON.stringify(store.agg.by_s[original.id])) - - const current = session(Date.parse("2026-01-02T00:00:00.000Z")) - - await reconcileSession(api(new Error("boom"), current), current.id) - - assert.deepEqual(store.agg.by_s[original.id], before) - assert.equal(store.agg.fresh[original.id]!.updated, current.time.updated) - assert.equal(store.agg.fresh[original.id]!.synced, original.time.updated) - }) -}) + const original = session(Date.parse("2026-01-01T00:00:00.000Z")); + const cachedMessage = message( + "cached", + Date.parse("2026-01-01T12:00:00.000Z"), + 5, + ); + putSess(store.agg, original); + putMsg(store.agg, cachedMessage); + store.agg.fresh[original.id] = { + updated: original.time.updated, + synced: original.time.updated, + }; + const before = JSON.parse(JSON.stringify(store.agg.by_s[original.id])); + + const current = session(Date.parse("2026-01-02T00:00:00.000Z")); + + await reconcileSession(api(new Error("boom"), current), current.id); + + assert.deepEqual(store.agg.by_s[original.id], before); + assert.equal(store.agg.fresh[original.id]!.updated, current.time.updated); + assert.equal(store.agg.fresh[original.id]!.synced, original.time.updated); + }); +}); describe("seed", () => { it("keeps successful sessions when one message fetch fails", async () => { - const staleCompleted = Date.parse("2025-12-31T12:00:00.000Z") + const staleCompleted = Date.parse("2025-12-31T12:00:00.000Z"); const staleSession = { ...session(Date.parse("2025-12-31T00:00:00.000Z")), id: "stale-session", - } - const staleAgg = createAgg() - staleAgg.ready = true - putSess(staleAgg, staleSession) - putMsg(staleAgg, sessionMessage(staleSession.id, "stale", staleCompleted, 5)) - - const okCompleted = Date.parse("2026-01-03T12:00:00.000Z") + }; + const staleAgg = createAgg(); + staleAgg.ready = true; + putSess(staleAgg, staleSession); + putMsg( + staleAgg, + sessionMessage(staleSession.id, "stale", staleCompleted, 5), + ); + + const okCompleted = Date.parse("2026-01-03T12:00:00.000Z"); const okSession = { ...session(Date.parse("2026-01-03T00:00:00.000Z")), id: "session-ok", - } + }; const badSession = { ...session(Date.parse("2026-01-04T00:00:00.000Z")), id: "session-bad", - } - const nextMessage = sessionMessage(okSession.id, "fresh", okCompleted, 20) - const completed = (nextMessage.time as { completed: number }).completed + }; + const nextMessage = sessionMessage(okSession.id, "fresh", okCompleted, 20); + const completed = (nextMessage.time as { completed: number }).completed; + const firstText = completed - 300; await seed( seedApi( [okSession, badSession], { - [okSession.id]: [{ info: nextMessage, parts: [] }], + [okSession.id]: [ + { + info: nextMessage, + parts: [textPart(nextMessage.id, firstText)], + }, + ], [badSession.id]: new Error("boom"), }, staleAgg, ), - ) - - const bucket = store.agg.by_s[okSession.id]! - assert.equal(store.agg.ready, true) - assert.equal(store.agg.meta[staleSession.id], undefined) - assert.equal(store.agg.by_s[staleSession.id], undefined) - assert.deepEqual(Object.keys(bucket), [dayKey(completed)]) - assert.equal(bucket[dayKey(completed)]!.totals.msg, 1) - assert.equal(store.agg.by_s[badSession.id], undefined) - assert.equal(store.agg.fresh[okSession.id]!.synced, okSession.time.updated) - assert.equal(store.agg.fresh[badSession.id]!.synced, badSession.time.updated) - }) -}) + ); + + const bucket = store.agg.by_s[okSession.id]!; + assert.equal(store.agg.ready, true); + assert.equal(store.agg.meta[staleSession.id], undefined); + assert.equal(store.agg.by_s[staleSession.id], undefined); + assert.deepEqual(Object.keys(bucket), [dayKey(completed)]); + assert.equal(bucket[dayKey(completed)]!.totals.msg, 1); + assert.equal(bucket[dayKey(completed)]!.speed["provider/model"]!.ms, 300); + assert.equal(store.agg.by_s[badSession.id], undefined); + assert.equal(store.agg.fresh[okSession.id]!.synced, okSession.time.updated); + assert.equal( + store.agg.fresh[badSession.id]!.synced, + badSession.time.updated, + ); + }); +}); diff --git a/src/reconcile.ts b/src/reconcile.ts index fc3d970..2c5ca56 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -1,56 +1,65 @@ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" -import { putMsg, putSess, putTool, rebuildDays } from "./agg" -import { bump, createAgg, keyFor, load, resetState, save, store } from "./state" -import { BATCH } from "./types" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { putMsg, putSess, putSpeed, putTool, rebuildDays } from "./agg"; +import { + bump, + createAgg, + keyFor, + load, + resetState, + save, + store, +} from "./state"; +import { BATCH } from "./types"; async function fetchSessionRows(api: TuiPluginApi, sessionID: string) { - const result = await api.client.session.messages({ sessionID }) - return result.data ?? [] + const result = await api.client.session.messages({ sessionID }); + return result.data ?? []; } export async function refreshSession(api: TuiPluginApi, sessionID: string) { return api.client.session .get({ sessionID }) .then((r) => { - if (!r.data) return - putSess(store.agg, r.data) - return r.data + if (!r.data) return; + putSess(store.agg, r.data); + return r.data; }) - .catch(() => undefined) + .catch(() => undefined); } export async function reconcileSession(api: TuiPluginApi, sessionID: string) { - if (store.sync.has(sessionID)) return - store.sync.add(sessionID) + if (store.sync.has(sessionID)) return; + store.sync.add(sessionID); try { - const session = await refreshSession(api, sessionID) - if (!session) return - let rows + const session = await refreshSession(api, sessionID); + if (!session) return; + let rows; try { - rows = await fetchSessionRows(api, sessionID) + rows = await fetchSessionRows(api, sessionID); } catch { - return + return; } - const next = createAgg() + const next = createAgg(); rows.forEach((row) => { - putMsg(next, row.info) - row.parts.forEach((part) => putTool(next, part)) - }) - if (next.by_s[sessionID]) store.agg.by_s[sessionID] = next.by_s[sessionID]! - else delete store.agg.by_s[sessionID] - rebuildDays() - store.flat.clear() - store.active.clear() - store.rows.clear() - const fresh = store.agg.fresh[sessionID] ?? { updated: 0, synced: 0 } - fresh.updated = Math.max(fresh.updated, session.time.updated) - fresh.synced = session.time.updated - store.agg.fresh[sessionID] = fresh - store.rev += 1 - save(api) - bump() + putMsg(next, row.info); + putSpeed(next, row.info, row.parts); + row.parts.forEach((part) => putTool(next, part)); + }); + if (next.by_s[sessionID]) store.agg.by_s[sessionID] = next.by_s[sessionID]!; + else delete store.agg.by_s[sessionID]; + rebuildDays(); + store.flat.clear(); + store.active.clear(); + store.rows.clear(); + const fresh = store.agg.fresh[sessionID] ?? { updated: 0, synced: 0 }; + fresh.updated = Math.max(fresh.updated, session.time.updated); + fresh.synced = session.time.updated; + store.agg.fresh[sessionID] = fresh; + store.rev += 1; + save(api); + bump(); } finally { - store.sync.delete(sessionID) + store.sync.delete(sessionID); } } @@ -58,76 +67,83 @@ export async function refreshProjectSessions(api: TuiPluginApi) { const list = await api.client.session .list() .then((r) => r.data ?? []) - .catch(() => []) - list.forEach((session) => putSess(store.agg, session)) - return list.filter((s) => (store.agg.fresh[s.id]?.synced ?? 0) < s.time.updated) + .catch(() => []); + list.forEach((session) => putSess(store.agg, session)); + return list.filter( + (s) => (store.agg.fresh[s.id]?.synced ?? 0) < s.time.updated, + ); } -export function scheduleReconcile(api: TuiPluginApi, sessionID: string, delay = 350) { - const prev = store.timers.get(sessionID) - if (prev) clearTimeout(prev) +export function scheduleReconcile( + api: TuiPluginApi, + sessionID: string, + delay = 350, +) { + const prev = store.timers.get(sessionID); + if (prev) clearTimeout(prev); const timer = setTimeout(() => { - store.timers.delete(sessionID) - void reconcileSession(api, sessionID) - }, delay) - store.timers.set(sessionID, timer) + store.timers.delete(sessionID); + void reconcileSession(api, sessionID); + }, delay); + store.timers.set(sessionID, timer); } export async function seed(api: TuiPluginApi) { - const key = keyFor(api) - if (store.state && store.state !== key) resetState() - store.state = key - if (store.wait) return store.wait + const key = keyFor(api); + if (store.state && store.state !== key) resetState(); + store.state = key; + if (store.wait) return store.wait; store.wait = (async () => { - load(api) - store.agg.v = 4 - store.agg.ready = false - bump() - let list + load(api); + store.agg.v = 4; + store.agg.ready = false; + bump(); + let list; try { - list = await api.client.session.list().then((r) => r.data ?? []) + list = await api.client.session.list().then((r) => r.data ?? []); } catch { - store.agg.ready = true - bump() - return + store.agg.ready = true; + bump(); + return; } - const next = createAgg() - list.forEach((session) => putSess(next, session)) + const next = createAgg(); + list.forEach((session) => putSess(next, session)); try { for (let i = 0; i < list.length; i += BATCH) { - const group = list.slice(i, i + BATCH) + const group = list.slice(i, i + BATCH); const rowsBySession = await Promise.all( group.map(async (session) => ({ rows: await fetchSessionRows(api, session.id).catch(() => []), })), - ) + ); rowsBySession.forEach(({ rows }) => { rows.forEach((row) => { - putMsg(next, row.info) - row.parts.forEach((part) => putTool(next, part)) - }) - }) + putMsg(next, row.info); + putSpeed(next, row.info, row.parts); + row.parts.forEach((part) => putTool(next, part)); + }); + }); } } catch { - store.agg.ready = true - bump() - return + store.agg.ready = true; + bump(); + return; } - store.agg = next + store.agg = next; for (const session of list) { - const fresh = store.agg.fresh[session.id] ?? { updated: 0, synced: 0 } - fresh.updated = Math.max(fresh.updated, session.time.updated) - fresh.synced = session.time.updated - store.agg.fresh[session.id] = fresh + const fresh = store.agg.fresh[session.id] ?? { updated: 0, synced: 0 }; + fresh.updated = Math.max(fresh.updated, session.time.updated); + fresh.synced = session.time.updated; + store.agg.fresh[session.id] = fresh; } - rebuildDays() - store.flat.clear() - store.active.clear() - store.rows.clear() - store.rev += 1 - store.agg.ready = true - save(api) - bump() - })() - return store.wait + rebuildDays(); + store.flat.clear(); + store.active.clear(); + store.rows.clear(); + store.rev += 1; + store.agg.ready = true; + save(api); + bump(); + })(); + return store.wait; } diff --git a/src/view.tsx b/src/view.tsx index 1750fc3..bae2837 100644 --- a/src/view.tsx +++ b/src/view.tsx @@ -1,187 +1,223 @@ -import type { TuiPluginApi } from "@opencode-ai/plugin/tui" -import { useKeyboard } from "@opentui/solid" -import { batch, createEffect, createSignal, For, onCleanup, Show } from "solid-js" -import { computeView } from "./compute" -import { Meter, Tabs } from "./components" -import { cycleSort, next, sortLabel, usd, valid } from "./format" -import { dbRev, store } from "./state" -import { reconcileSession, refreshProjectSessions, seed } from "./reconcile" -import { DEFAULT_SORT, EMPTY_VIEW, scopes, scopeKey, sectionKey, sections, winKey, wins } from "./types" -import type { Back, ScopeParam, SortState, Win } from "./types" +import type { TuiPluginApi } from "@opencode-ai/plugin/tui"; +import { useKeyboard } from "@opentui/solid"; +import { + batch, + createEffect, + createSignal, + For, + onCleanup, + Show, +} from "solid-js"; +import { computeView } from "./compute"; +import { Meter, Tabs } from "./components"; +import { cycleSort, next, sortLabel, usd, valid } from "./format"; +import { dbRev, store } from "./state"; +import { reconcileSession, refreshProjectSessions, seed } from "./reconcile"; +import { + DEFAULT_SORT, + EMPTY_VIEW, + scopes, + scopeKey, + sectionKey, + sections, + winKey, + wins, +} from "./types"; +import type { Back, ScopeParam, SortState, Win } from "./types"; export function View(props: { - api: TuiPluginApi - keys: ReturnType - params?: Record + api: TuiPluginApi; + keys: ReturnType; + params?: Record; }) { - const [section, setSection] = createSignal(valid(props.api.kv.get(sectionKey), sections, "models")) - const [scope, setScope] = createSignal<(typeof scopes)[number]>(valid(props.api.kv.get(scopeKey), scopes, "project")) - const [win, setWin] = createSignal(valid(props.api.kv.get(winKey), wins, "7d")) - const [sort, setSort] = createSignal(DEFAULT_SORT) - const [view, setView] = createSignal(EMPTY_VIEW) - const [loading, setLoading] = createSignal(false) - const [refreshing, setRefreshing] = createSignal(0) - const [spin, setSpin] = createSignal(0) + const [section, setSection] = createSignal( + valid(props.api.kv.get(sectionKey), sections, "models"), + ); + const [scope, setScope] = createSignal<(typeof scopes)[number]>( + valid(props.api.kv.get(scopeKey), scopes, "project"), + ); + const [win, setWin] = createSignal( + valid(props.api.kv.get(winKey), wins, "7d"), + ); + const [sort, setSort] = createSignal(DEFAULT_SORT); + const [view, setView] = createSignal(EMPTY_VIEW); + const [loading, setLoading] = createSignal(false); + const [refreshing, setRefreshing] = createSignal(0); + const [spin, setSpin] = createSignal(0); - const routeParams = (): Record => ({ ...props.params, directory: props.api.state.path.directory }) + const routeParams = (): Record => ({ + ...props.params, + directory: props.api.state.path.directory, + }); const scopeParam = (): ScopeParam => { - const p = routeParams() - const sid = typeof p.sessionID === "string" ? p.sessionID : undefined - const session = sid ? store.agg.meta[sid] : undefined - if (session) return { pid: session.pid, dir: session.dir } - const dir = typeof p.directory === "string" && p.directory ? p.directory : undefined - return { pid: undefined, dir } - } + const p = routeParams(); + const sid = typeof p.sessionID === "string" ? p.sessionID : undefined; + const session = sid ? store.agg.meta[sid] : undefined; + if (session) return { pid: session.pid, dir: session.dir }; + const dir = + typeof p.directory === "string" && p.directory ? p.directory : undefined; + return { pid: undefined, dir }; + }; const sessionID = () => { - const p = routeParams() - return typeof p.sessionID === "string" ? p.sessionID : undefined - } + const p = routeParams(); + return typeof p.sessionID === "string" ? p.sessionID : undefined; + }; const back = (): Back | undefined => { - const p = routeParams() - const back = p.back - if (!back || typeof back !== "object") return - if (!("name" in back) || typeof back.name !== "string") return - if (!("params" in back) || back.params === undefined) return { name: back.name } - if (!back.params || typeof back.params !== "object") return - return { name: back.name, params: back.params as Record } - } + const p = routeParams(); + const back = p.back; + if (!back || typeof back !== "object") return; + if (!("name" in back) || typeof back.name !== "string") return; + if (!("params" in back) || back.params === undefined) + return { name: back.name }; + if (!back.params || typeof back.params !== "object") return; + return { name: back.name, params: back.params as Record }; + }; - let persisted = false + let persisted = false; const persistPrefs = () => { - if (persisted) return - persisted = true - const currentSection = section() - const currentScope = scope() - const currentWin = win() + if (persisted) return; + persisted = true; + const currentSection = section(); + const currentScope = scope(); + const currentWin = win(); setTimeout(() => { - props.api.kv.set(sectionKey, currentSection) - props.api.kv.set(scopeKey, currentScope) - props.api.kv.set(winKey, currentWin) - }, 0) - } + props.api.kv.set(sectionKey, currentSection); + props.api.kv.set(scopeKey, currentScope); + props.api.kv.set(winKey, currentWin); + }, 0); + }; onCleanup(() => { - persistPrefs() - }) + persistPrefs(); + }); const leave = () => { - persistPrefs() - const prev = back() - if (!prev || prev.name === "usage") return props.api.route.navigate("home") - props.api.route.navigate(prev.name, prev.params) - } + persistPrefs(); + const prev = back(); + if (!prev || prev.name === "usage") return props.api.route.navigate("home"); + props.api.route.navigate(prev.name, prev.params); + }; createEffect(() => { - if (!loading() && !refreshing()) return - const timer = setInterval(() => setSpin((x) => (x + 1) % 4), 120) - onCleanup(() => clearInterval(timer)) - }) + if (!loading() && !refreshing()) return; + const timer = setInterval(() => setSpin((x) => (x + 1) % 4), 120); + onCleanup(() => clearInterval(timer)); + }); - const spinner = () => ["|", "/", "-", "\\"][spin()] ?? "|" + const spinner = () => ["|", "/", "-", "\\"][spin()] ?? "|"; createEffect(() => { - const dir = props.api.state.path.directory - const sid = sessionID() - dir - sid - let active = true + const dir = props.api.state.path.directory; + const sid = sessionID(); + dir; + sid; + let active = true; onCleanup(() => { - active = false - }) - ;(async () => { - setLoading(true) - await seed(props.api) - if (!active) return - const stale = await refreshProjectSessions(props.api) - if (!active) return - const current = sid ? stale.find((item) => item.id === sid) : undefined - if (current) await reconcileSession(props.api, current.id) - if (!active) return - setLoading(false) - const rest = stale.filter((item) => item.id !== sid) - if (!rest.length) return - setRefreshing(rest.length) + active = false; + }); + (async () => { + setLoading(true); + await seed(props.api); + if (!active) return; + const stale = await refreshProjectSessions(props.api); + if (!active) return; + const current = sid ? stale.find((item) => item.id === sid) : undefined; + if (current) await reconcileSession(props.api, current.id); + if (!active) return; + setLoading(false); + const rest = stale.filter((item) => item.id !== sid); + if (!rest.length) return; + setRefreshing(rest.length); for (const session of rest) { - await new Promise((resolve) => setTimeout(resolve, 0)) - if (!active) return - await reconcileSession(props.api, session.id) - if (!active) return - setRefreshing((count) => Math.max(0, count - 1)) + await new Promise((resolve) => setTimeout(resolve, 0)); + if (!active) return; + await reconcileSession(props.api, session.id); + if (!active) return; + setRefreshing((count) => Math.max(0, count - 1)); } })().finally(() => { - if (!active) return - setLoading(false) - setRefreshing(0) - }) - }) + if (!active) return; + setLoading(false); + setRefreshing(0); + }); + }); createEffect(() => { - const dir = props.api.state.path.directory - const currentSection = section() - const currentScope = scope() - const currentWin = win() - const currentSort = sort() - dbRev() - dir - setView(computeView(currentSection, currentScope, currentWin, sessionID(), scopeParam(), currentSort)) - }) + const dir = props.api.state.path.directory; + const currentSection = section(); + const currentScope = scope(); + const currentWin = win(); + const currentSort = sort(); + dbRev(); + dir; + setView( + computeView( + currentSection, + currentScope, + currentWin, + sessionID(), + scopeParam(), + currentSort, + ), + ); + }); - const pickScope = (value: (typeof scopes)[number]) => setScope(value) - const pickSection = (value: (typeof sections)[number]) => setSection(value) - const pickWin = (value: Win) => setWin(value) - const theme = () => props.api.theme.current - const h = () => view().head + const pickScope = (value: (typeof scopes)[number]) => setScope(value); + const pickSection = (value: (typeof sections)[number]) => setSection(value); + const pickWin = (value: Win) => setWin(value); + const theme = () => props.api.theme.current; + const h = () => view().head; useKeyboard((evt) => { if (props.keys.match("quit", evt)) { - evt.preventDefault() - evt.stopPropagation() - return leave() + evt.preventDefault(); + evt.stopPropagation(); + return leave(); } if (props.keys.match("scope", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => pickScope(next(scopes, scope(), 1))) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => pickScope(next(scopes, scope(), 1))); } if (props.keys.match("section", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => pickSection(next(sections, section(), 1))) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => pickSection(next(sections, section(), 1))); } if (props.keys.match("section_back", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => pickSection(next(sections, section(), -1))) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => pickSection(next(sections, section(), -1))); } if (props.keys.match("sort", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => setSort((cur) => cycleSort(section(), cur))) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => setSort((cur) => cycleSort(section(), cur))); } if (props.keys.match("win", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => pickWin(next(wins, win(), 1))) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => pickWin(next(wins, win(), 1))); } if (props.keys.match("win_7d", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => pickWin("7d")) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => pickWin("7d")); } if (props.keys.match("win_30d", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => pickWin("30d")) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => pickWin("30d")); } if (props.keys.match("win_all", evt)) { - evt.preventDefault() - evt.stopPropagation() - return batch(() => pickWin("all")) + evt.preventDefault(); + evt.stopPropagation(); + return batch(() => pickWin("all")); } - }) + }); return ( @@ -190,29 +226,56 @@ export function View(props: { fg={theme().textMuted} >{`${h().sessions} sessions ${h().msg} msg ${h().tool} tools ${usd(h().cost)} ${h().hit}% cache`} - {`Loading usage history... ${spinner()}`} + {`Loading usage history... ${spinner()}`} 0}> {`Refreshing usage... ${spinner()}`} - + - End-to-end output rate from completed messages; not decode-only model TPS. Calculated as output tokens divided - by total assistant message duration. + Output rate from completed messages after first assistant text is + persisted; not decode-only model TPS. Calculated as output tokens + divided by the time between first assistant text and message + completion. - {`sort [${sortLabel(section(), sort())}]`} + {`sort [${sortLabel(section(), sort())}]`} Open /usage from a session to use session scope.} + fallback={ + + Open /usage from a session to use session scope. + + } > {view().sync ? "Loading history..." : "Waiting for cache..."}} + fallback={ + + {view().sync ? "Loading history..." : "Waiting for cache..."} + + } > - 0} fallback={No usage yet for this filter.}> - {(row) => } + 0} + fallback={ + No usage yet for this filter. + } + > + + {(row) => } + @@ -220,5 +283,5 @@ export function View(props: { fg={theme().textMuted} >{`[${props.keys.print("scope")}] scope [${props.keys.print("section")}] section [${props.keys.print("sort")}] sort [${props.keys.print("win")}] window [${props.keys.print("win_7d")}/${props.keys.print("win_30d")}/${props.keys.print("win_all")}] jump [${props.keys.print("quit")}] back`} - ) + ); }