diff --git a/README.md b/README.md index 1d433c0..dc4fd42 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,16 @@ Repository: `https://github.com/cosmiclasagnadev/opencode-usage` ## Install -```bash -opencode plugin opencode-usage-dashboard +Add it to `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-usage-dashboard"] +} ``` -Or add it to `tui.json`: +If you keep plugin config in `tui.json`, that works too: ```json { @@ -52,4 +57,4 @@ bun run build ## Release -The standalone repo is intended to publish through GitHub Actions with npm trusted publishing. +The standalone repo is intended to publish through GitHub Actions with npm trusted publishing. \ No newline at end of file diff --git a/package.json b/package.json index d9f83f5..3f724c4 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.1", + "version": "1.0.2", "description": "TUI usage dashboard plugin for OpenCode", "type": "module", "license": "MIT", diff --git a/src/agg.test.ts b/src/agg.test.ts new file mode 100644 index 0000000..36a9fa1 --- /dev/null +++ b/src/agg.test.ts @@ -0,0 +1,87 @@ +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" + +function message(overrides: Partial = {}) { + return { + id: "msg-1", + role: "assistant", + sessionID: "session-1", + providerID: "provider", + modelID: "model", + agent: "build", + cost: 1.25, + error: undefined, + tokens: { + input: 10, + output: 20, + reasoning: 0, + cache: { read: 2, write: 0 }, + }, + time: { + created: Date.parse("2026-01-02T10:00:00.000Z"), + completed: Date.parse("2026-01-02T10:00:05.000Z"), + }, + ...overrides, + } as Message +} + +function tool(status: "running" | "completed" | "error"): Part { + if (status === "running") { + return { + id: "tool-1", + type: "tool", + sessionID: "session-1", + tool: "bash", + state: { status: "running" }, + } as Part + } + return { + id: "tool-1", + type: "tool", + sessionID: "session-1", + tool: "bash", + state: { + status, + time: { + start: Date.parse("2026-01-02T10:00:00.000Z"), + end: Date.parse("2026-01-02T10:00:02.000Z"), + }, + }, + } 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 + + 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("ignores non-terminal tool updates", () => { + const agg = createAgg() + const completed = tool("completed") as Part & { + 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, 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) + }) +}) diff --git a/src/agg.ts b/src/agg.ts index f0241e4..d7d1be1 100644 --- a/src/agg.ts +++ b/src/agg.ts @@ -1,5 +1,6 @@ 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) { @@ -31,29 +32,6 @@ export function noteOfError(err: unknown) { return "error" } -export function msgSig(msg: AssistantMessage) { - return [ - msg.id, - msg.time.created, - msg.time.completed ?? 0, - msg.providerID, - msg.modelID, - msg.agent, - msg.cost, - msg.tokens.input, - msg.tokens.output, - msg.tokens.reasoning, - noteOfError(msg.error), - ].join(":") -} - -export function toolSig(part: ToolPart) { - const s = part.state - const end = "time" in s && s.time && "end" in s.time ? (s.time.end ?? 0) : 0 - const start = "time" in s && s.time ? s.time.start : 0 - return [part.id, part.tool, s.status, start, end].join(":") -} - export function isAssistant(msg: Message): msg is AssistantMessage { return msg.role === "assistant" } @@ -62,15 +40,15 @@ export function isTool(part: Part): part is ToolPart { return part.type === "tool" } -export function sessDay(sid: string, dk: string): SessDay { - let sd = store.agg.by_s[sid] +export function sessDay(agg: Agg, sid: string, dk: string): SessDay { + let sd = agg.by_s[sid] if (!sd) { sd = {} - store.agg.by_s[sid] = sd + agg.by_s[sid] = sd } let d = sd[dk] if (!d) { - if (!store.agg.days.includes(dk)) store.agg.days.push(dk) + if (!agg.days.includes(dk)) agg.days.push(dk) d = { models: {}, tools: {}, @@ -84,21 +62,18 @@ export function sessDay(sid: string, dk: string): SessDay { return d } -export function putSess(s: Session) { - store.agg.meta[s.id] = { id: s.id, pid: s.projectID, dir: s.directory } - const fresh = store.agg.fresh[s.id] ?? { updated: 0, synced: 0 } +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) - store.agg.fresh[s.id] = fresh + agg.fresh[s.id] = fresh } -export function putMsg(msg: Message) { +export function putMsg(agg: Agg, msg: Message) { if (!isAssistant(msg)) return false if (!msg.time.completed) return false - const sig = msgSig(msg) - if (store.agg.seen.msg[msg.id] === sig) return false - store.agg.seen.msg[msg.id] = sig const dk = dayKey(msg.time.completed ?? msg.time.created) - const b = sessDay(msg.sessionID, dk) + 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 } @@ -124,43 +99,16 @@ export function putMsg(msg: Message) { b.totals.cost += msg.cost b.totals.input += msg.tokens.input + msg.tokens.cache.read b.totals.cache += msg.tokens.cache.read - if (!store.seed) { - const gf = store.agg.gf - if (!gf.models[mid]) gf.models[mid] = { n: 0, cost: 0, input: 0, output: 0 } - gf.models[mid]!.n += 1 - gf.models[mid]!.cost += msg.cost - gf.models[mid]!.input += msg.tokens.input - gf.models[mid]!.output += msg.tokens.output - if (!gf.agents[aid]) gf.agents[aid] = { n: 0, cost: 0, output: 0 } - gf.agents[aid]!.n += 1 - gf.agents[aid]!.cost += msg.cost - gf.agents[aid]!.output += msg.tokens.output - if (msg.time.completed > msg.time.created) { - if (!gf.speed[mid]) gf.speed[mid] = { out: 0, ms: 0, n: 0 } - gf.speed[mid]!.out += msg.tokens.output - gf.speed[mid]!.ms += msg.time.completed - msg.time.created - gf.speed[mid]!.n += 1 - } - if (msg.error) - gf.errors[`assistant:${noteOfError(msg.error)}`] = (gf.errors[`assistant:${noteOfError(msg.error)}`] ?? 0) + 1 - gf.totals.msg += 1 - gf.totals.cost += msg.cost - gf.totals.input += msg.tokens.input + msg.tokens.cache.read - gf.totals.cache += msg.tokens.cache.read - } - store.rev += 1 return true } -export function putTool(part: Part) { +export function putTool(agg: Agg, part: Part) { if (!isTool(part)) return false - const sig = toolSig(part) - if (store.agg.seen.tool[part.id] === sig) return false - store.agg.seen.tool[part.id] = sig const s = part.state - const ts = "time" in s && s.time && "end" in s.time ? (s.time.end ?? 0) : 0 - const dk = dayKey(ts || Date.now()) - const b = sessDay(part.sessionID, dk) + 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") { @@ -172,21 +120,6 @@ export function putTool(part: Part) { t.ms += s.time.end - s.time.start } b.totals.tool += 1 - if (!store.seed) { - const gf = store.agg.gf - if (!gf.tools[part.tool]) gf.tools[part.tool] = { n: 0, err: 0, ms: 0 } - const gt = gf.tools[part.tool]! - if (s.status === "completed") { - gt.n += 1 - gt.ms += s.time.end - s.time.start - } else if (s.status === "error") { - gt.n += 1 - gt.err += 1 - gt.ms += s.time.end - s.time.start - } - gf.totals.tool += 1 - } - store.rev += 1 return true } diff --git a/src/components.tsx b/src/components.tsx index 08c7d74..dd93e67 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -22,7 +22,7 @@ export function Tabs(props: { api: TuiPluginApi label: string value: Value - list: Value[] + list: readonly Value[] pick: (value: Value) => void }) { const theme = () => props.api.theme.current diff --git a/src/compute.ts b/src/compute.ts index 3f6e945..3ca7dc2 100644 --- a/src/compute.ts +++ b/src/compute.ts @@ -4,6 +4,22 @@ import { store } from "./state" import { EMPTY_VIEW } from "./types" import type { FlatCounters, Row, Scope, ScopeParam, Section, SortState, ViewData, Win } from "./types" +function viewKey(scope: Scope, sid: string | undefined, sp: ScopeParam, win: Win) { + return `${store.rev}:${scope}:${sid ?? ""}:${sp.pid ?? ""}:${sp.dir ?? ""}:${win}` +} + +function sectionSortKey(section: Section, sort: SortState) { + if (section === "models") return sort.models + if (section === "agents") return sort.agents + if (section === "tools") return sort.tools + if (section === "speed") return sort.speed + return sort.errors +} + +function rowsKey(section: Section, scope: Scope, sid: string | undefined, sp: ScopeParam, win: Win, sort: SortState) { + return `${viewKey(scope, sid, sp, win)}:${section}:${sectionSortKey(section, sort)}` +} + export function filterIds(scope: Scope, sid: string | undefined, sp: ScopeParam) { const meta = store.agg.meta if (scope === "global") return new Set(Object.keys(meta)) @@ -70,15 +86,9 @@ export function buildFlat(allowed: Set, win: Win): FlatCounters { } export function collectFlat(scope: Scope, sid: string | undefined, sp: ScopeParam, win: Win): FlatCounters { - const key = `${store.rev}:${scope}:${sid ?? ""}:${sp.pid ?? ""}:${sp.dir ?? ""}:${win}` + const key = viewKey(scope, sid, sp, win) const hit = store.flat.get(key) if (hit) return hit - if (win === "all" && scope === "global") { - store.flat.set(key, store.agg.gf) - const old = store.flat.keys().next().value - if (store.flat.size > 12 && old) store.flat.delete(old) - return store.agg.gf - } const flat = buildFlat(filterIds(scope, sid, sp), win) store.flat.set(key, flat) const old = store.flat.keys().next().value @@ -105,6 +115,36 @@ export function activeSessions(allowed: Set, win: Win) { return count } +export function collectActiveSessions(scope: Scope, sid: string | undefined, sp: ScopeParam, win: Win) { + const key = viewKey(scope, sid, sp, win) + const hit = store.active.get(key) + if (hit != null) return hit + const count = activeSessions(filterIds(scope, sid, sp), win) + store.active.set(key, count) + const old = store.active.keys().next().value + if (store.active.size > 12 && old) store.active.delete(old) + return count +} + +export function collectRows( + section: Section, + scope: Scope, + sid: string | undefined, + sp: ScopeParam, + win: Win, + flat: FlatCounters, + sort: SortState, +) { + const key = rowsKey(section, scope, sid, sp, win, sort) + const hit = store.rows.get(key) + if (hit) return hit + const rows = rowsFor(section, flat, sort) + store.rows.set(key, rows) + const old = store.rows.keys().next().value + if (store.rows.size > 36 && old) store.rows.delete(old) + return rows +} + export function rowsFor(section: Section, flat: FlatCounters, sort: SortState): Row[] { if (section === "models") { const e = Object.entries(flat.models) @@ -203,9 +243,9 @@ export function computeView( const flat = collectFlat(scope, sid, sp, win) const input = flat.totals.input return { - rows: rowsFor(section, flat, sort), + rows: collectRows(section, scope, sid, sp, win, flat, sort), head: { - sessions: activeSessions(allowed, win), + sessions: collectActiveSessions(scope, sid, sp, win), msg: flat.totals.msg, tool: flat.totals.tool, cost: flat.totals.cost, diff --git a/src/plugin.tsx b/src/plugin.tsx index d082fe9..8aa3068 100644 --- a/src/plugin.tsx +++ b/src/plugin.tsx @@ -1,7 +1,6 @@ import type { TuiPlugin } from "@opencode-ai/plugin/tui" -import { putMsg, putTool } from "./agg" -import { scheduleReconcile, refreshSession, seed } from "./reconcile" -import { bump, save, store } from "./state" +import { scheduleReconcile, seed } from "./reconcile" +import { resetState } from "./state" import { View } from "./view" export const tui: TuiPlugin = async (api) => { @@ -17,7 +16,7 @@ export const tui: TuiPlugin = async (api) => { win_all: "3", }) - api.command.register(() => [ + const disposeCommand = api.command.register(() => [ { title: "OpenCode Usage", description: "Open model, agent, tool and error usage", @@ -35,7 +34,7 @@ export const tui: TuiPlugin = async (api) => { }, ]) - api.route.register([ + const disposeRoute = api.route.register([ { name: "usage", render(input) { @@ -44,26 +43,22 @@ export const tui: TuiPlugin = async (api) => { }, ]) - api.event.on("message.updated", (evt) => { - const changed = putMsg(evt.properties.info) - void refreshSession(api, evt.properties.sessionID).then((session) => { - if (!session) return - if ((store.agg.fresh[session.id]?.synced ?? 0) < session.time.updated) scheduleReconcile(api, session.id) - }) - if (!changed) return - save(api) - bump() + const scheduleSession = (sessionID: string) => scheduleReconcile(api, sessionID) + + const disposeMessage = api.event.on("message.updated", (evt) => { + scheduleSession(evt.properties.sessionID) + }) + + const disposePart = api.event.on("message.part.updated", (evt) => { + scheduleSession(evt.properties.sessionID) }) - api.event.on("message.part.updated", (evt) => { - const changed = putTool(evt.properties.part) - void refreshSession(api, evt.properties.sessionID).then((session) => { - if (!session) return - if ((store.agg.fresh[session.id]?.synced ?? 0) < session.time.updated) scheduleReconcile(api, session.id) - }) - if (!changed) return - save(api) - bump() + api.lifecycle.onDispose(() => { + disposePart() + disposeMessage() + disposeRoute() + disposeCommand() + resetState() }) void seed(api) diff --git a/src/reconcile.test.ts b/src/reconcile.test.ts new file mode 100644 index 0000000..c7f418f --- /dev/null +++ b/src/reconcile.test.ts @@ -0,0 +1,187 @@ +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 { + id: "session-1", + projectID: "project-1", + directory: "/tmp/project", + time: { + updated, + }, + } as Session +} + +function message(id: string, completed: number, output: number): Message { + return { + id, + role: "assistant", + sessionID: "session-1", + providerID: "provider", + modelID: "model", + agent: "build", + cost: output / 10, + error: undefined, + tokens: { + input: 10, + output, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + time: { + created: completed - 1000, + completed, + }, + } as Message +} + +function sessionMessage(sessionID: string, id: string, completed: number, output: number): Message { + return { + ...message(id, completed, output), + sessionID, + } as Message +} + +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 } + }, + }, + }, + kv: { + get: () => undefined, + set: () => undefined, + ready: true, + }, + state: { + path: { + directory: "/tmp/project", + }, + }, + } as any +} + +function seedApi( + list: Session[], + rowsBySession: Record, + cached?: unknown, +) { + return { + client: { + session: { + list: async () => ({ data: list }), + messages: async ({ sessionID }: { sessionID: string }) => { + const rows = rowsBySession[sessionID] + if (rows instanceof Error) throw rows + return { data: rows ?? [] } + }, + }, + }, + kv: { + get: () => cached, + set: () => undefined, + ready: true, + }, + state: { + path: { + directory: "/tmp/project", + }, + }, + } as any +} + +afterEach(() => { + 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) + }) + + 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) + }) +}) + +describe("seed", () => { + it("keeps successful sessions when one message fetch fails", async () => { + 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 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 + + await seed( + seedApi( + [okSession, badSession], + { + [okSession.id]: [{ info: nextMessage, parts: [] }], + [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) + }) +}) diff --git a/src/reconcile.ts b/src/reconcile.ts index 07f6364..fc3d970 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -1,15 +1,19 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { putMsg, putSess, putTool, rebuildDays } from "./agg" -import { buildFlat } from "./compute" -import { bump, keyFor, load, resetState, save, store } from "./state" +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 ?? [] +} + export async function refreshSession(api: TuiPluginApi, sessionID: string) { return api.client.session .get({ sessionID }) .then((r) => { if (!r.data) return - putSess(r.data) + putSess(store.agg, r.data) return r.data }) .catch(() => undefined) @@ -20,31 +24,28 @@ export async function reconcileSession(api: TuiPluginApi, sessionID: string) { store.sync.add(sessionID) try { const session = await refreshSession(api, sessionID) - const rows = await api.client.session - .messages({ sessionID }) - .then((r) => r.data ?? []) - .catch(() => []) - delete store.agg.by_s[sessionID] - for (const row of rows) { - delete store.agg.seen.msg[row.info.id] - for (const part of row.parts) if (part.type === "tool") delete store.agg.seen.tool[part.id] + if (!session) return + let rows + try { + rows = await fetchSessionRows(api, sessionID) + } catch { + return } - const prev = store.seed - store.seed = true + const next = createAgg() rows.forEach((row) => { - putMsg(row.info) - row.parts.forEach(putTool) + putMsg(next, row.info) + row.parts.forEach((part) => putTool(next, part)) }) - store.seed = prev + if (next.by_s[sessionID]) store.agg.by_s[sessionID] = next.by_s[sessionID]! + else delete store.agg.by_s[sessionID] rebuildDays() - store.agg.gf = buildFlat(new Set(Object.keys(store.agg.meta)), "all") store.flat.clear() - if (session) { - 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.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() @@ -58,7 +59,7 @@ export async function refreshProjectSessions(api: TuiPluginApi) { .list() .then((r) => r.data ?? []) .catch(() => []) - list.forEach(putSess) + list.forEach((session) => putSess(store.agg, session)) return list.filter((s) => (store.agg.fresh[s.id]?.synced ?? 0) < s.time.updated) } @@ -81,37 +82,49 @@ export async function seed(api: TuiPluginApi) { load(api) store.agg.v = 4 store.agg.ready = false - store.seed = true bump() - const list = await api.client.session - .list() - .then((r) => r.data ?? []) - .catch(() => []) - list.forEach(putSess) - for (let i = 0; i < list.length; i += BATCH) { - const group = list.slice(i, i + BATCH) - await Promise.all( - group.map(async (s) => { - const rows = await api.client.session - .messages({ sessionID: s.id }) - .then((r) => r.data ?? []) - .catch(() => []) + let list + try { + list = await api.client.session.list().then((r) => r.data ?? []) + } catch { + store.agg.ready = true + bump() + return + } + 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 rowsBySession = await Promise.all( + group.map(async (session) => ({ + rows: await fetchSessionRows(api, session.id).catch(() => []), + })), + ) + rowsBySession.forEach(({ rows }) => { rows.forEach((row) => { - putMsg(row.info) - row.parts.forEach(putTool) + putMsg(next, row.info) + row.parts.forEach((part) => putTool(next, part)) }) - }), - ) + }) + } + } catch { + store.agg.ready = true + bump() + return } - store.seed = false - rebuildDays() - store.agg.gf = buildFlat(new Set(Object.keys(store.agg.meta)), "all") + 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 } + rebuildDays() + store.flat.clear() + store.active.clear() + store.rows.clear() + store.rev += 1 store.agg.ready = true save(api) bump() diff --git a/src/state.ts b/src/state.ts index 491b3ab..c72f50a 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,9 +1,22 @@ import type { TuiPluginApi } from "@opencode-ai/plugin/tui" import { createSignal } from "solid-js" import { EMPTY_FLAT } from "./types" -import type { Agg, FlatCounters } from "./types" +import type { Agg, FlatCounters, Row } from "./types" -function emptyAgg(): Agg { +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function isAgg(value: unknown): value is Agg { + if (!isRecord(value)) return false + if (value.v !== 4 || typeof value.ready !== "boolean") return false + if (!isRecord(value.meta) || !isRecord(value.by_s) || !Array.isArray(value.days)) return false + if (!isRecord(value.gf) || !isRecord(value.fresh)) return false + if (!isRecord(value.gf.totals)) return false + return true +} + +export function createAgg(): Agg { return { v: 4, ready: false, @@ -12,19 +25,19 @@ function emptyAgg(): Agg { days: [], gf: { ...EMPTY_FLAT }, fresh: {}, - seen: { msg: {}, tool: {} }, } } export const store = { - agg: emptyAgg(), + agg: createAgg(), wait: undefined as Promise | undefined, save: undefined as ReturnType | undefined, dirty: false, rev: 0, - seed: false, bump: undefined as ReturnType | undefined, flat: new Map(), + active: new Map(), + rows: new Map(), state: "", sync: new Set(), timers: new Map>(), @@ -42,13 +55,16 @@ export function bump() { } export function resetState() { - store.agg = emptyAgg() + store.agg = createAgg() store.wait = undefined store.dirty = false - store.seed = false if (store.save) clearTimeout(store.save) if (store.bump) clearTimeout(store.bump) + store.save = undefined + store.bump = undefined store.flat.clear() + store.active.clear() + store.rows.clear() store.sync.clear() for (const timer of store.timers.values()) clearTimeout(timer) store.timers.clear() @@ -67,8 +83,7 @@ export function save(api: TuiPluginApi) { } export function load(api: TuiPluginApi) { - const hit = api.kv.get(keyFor(api)) - if (!hit || hit.v !== 4) return - if (!Array.isArray(hit.days)) hit.days = [] + const hit = api.kv.get(keyFor(api)) + if (!isAgg(hit)) return store.agg = hit } diff --git a/src/types.ts b/src/types.ts index 8db77be..9e0b118 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,7 +57,6 @@ export type Agg = { days: string[] gf: FlatCounters fresh: Record - seen: { msg: Record; tool: Record } } export type ScopeParam = { pid?: string; dir?: string } diff --git a/src/view.tsx b/src/view.tsx index dd558c4..1750fc3 100644 --- a/src/view.tsx +++ b/src/view.tsx @@ -49,7 +49,26 @@ export function View(props: { return { name: back.name, params: back.params as Record } } + let persisted = false + const persistPrefs = () => { + 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) + } + + onCleanup(() => { + 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) @@ -68,43 +87,53 @@ export function View(props: { 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) 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)) } })().finally(() => { + if (!active) return setLoading(false) setRefreshing(0) }) }) createEffect(() => { - const s = section() - const sc = scope() - const w = win() - const o = sort() const dir = props.api.state.path.directory + const currentSection = section() + const currentScope = scope() + const currentWin = win() + const currentSort = sort() dbRev() dir - setView(computeView(s, sc, w, sessionID(), scopeParam(), o)) + setView(computeView(currentSection, currentScope, currentWin, sessionID(), scopeParam(), currentSort)) }) - const pickScope = (v: (typeof scopes)[number]) => setScope(v) - const pickSection = (v: (typeof sections)[number]) => setSection(v) - const pickWin = (v: Win) => setWin(v) + 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 v = () => view() - const h = () => v().head + const h = () => view().head useKeyboard((evt) => { if (props.keys.match("quit", evt)) { @@ -150,7 +179,7 @@ export function View(props: { if (props.keys.match("win_all", evt)) { evt.preventDefault() evt.stopPropagation() - batch(() => pickWin("all")) + return batch(() => pickWin("all")) } }) @@ -166,7 +195,7 @@ export function View(props: { 0}> {`Refreshing usage... ${spinner()}`} - + End-to-end output rate from completed messages; not decode-only model TPS. Calculated as output tokens divided @@ -179,11 +208,11 @@ export function View(props: { fallback={Open /usage from a session to use session scope.} > {v().sync ? "Loading history..." : "Waiting for cache..."}} + when={view().ready} + 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) => }