From e9110efd39f92c8a745709419fa0ad42126e3396 Mon Sep 17 00:00:00 2001 From: cosmiclasagnadev Date: Wed, 8 Apr 2026 19:29:14 +0800 Subject: [PATCH 1/2] fix usage dashboard reconcile and responsiveness Make usage aggregation rebuild session data atomically and move dashboard persistence and view work off the hot path so navigation stays responsive while refreshing history. Made-with: Cursor --- README.md | 13 ++++-- package.json | 2 +- src/agg.test.ts | 87 +++++++++++++++++++++++++++++++++++ src/agg.ts | 99 +++++++--------------------------------- src/components.tsx | 2 +- src/compute.ts | 58 +++++++++++++++++++---- src/plugin.tsx | 41 ++++++++--------- src/reconcile.test.ts | 104 ++++++++++++++++++++++++++++++++++++++++++ src/reconcile.ts | 103 +++++++++++++++++++++++------------------ src/state.ts | 35 ++++++++++---- src/types.ts | 1 - src/view.tsx | 61 ++++++++++++++++++------- 12 files changed, 413 insertions(+), 193 deletions(-) create mode 100644 src/agg.test.ts create mode 100644 src/reconcile.test.ts 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..70de4c3 --- /dev/null +++ b/src/reconcile.test.ts @@ -0,0 +1,104 @@ +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 } from "./reconcile" +import { 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 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 +} + +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) + }) +}) diff --git a/src/reconcile.ts b/src/reconcile.ts index 07f6364..f237ca0 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), + })), + ) + 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) => } From 2362c328fd945e80b8c4d3032cee337b47c68b3c Mon Sep 17 00:00:00 2001 From: cosmiclasagnadev Date: Wed, 8 Apr 2026 20:34:55 +0800 Subject: [PATCH 2/2] fix seed session fetch fallback Made-with: Cursor --- src/reconcile.test.ts | 87 ++++++++++++++++++++++++++++++++++++++++++- src/reconcile.ts | 2 +- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/src/reconcile.test.ts b/src/reconcile.test.ts index 70de4c3..c7f418f 100644 --- a/src/reconcile.test.ts +++ b/src/reconcile.test.ts @@ -2,8 +2,8 @@ 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 } from "./reconcile" -import { resetState, store } from "./state" +import { reconcileSession, seed } from "./reconcile" +import { createAgg, resetState, store } from "./state" function session(updated: number): Session { return { @@ -39,6 +39,13 @@ function message(id: string, completed: number, output: number): Message { } 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: { @@ -63,6 +70,35 @@ function api(rows: { info: Message; parts: Part[] }[] | Error, current: Session) } 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() }) @@ -102,3 +138,50 @@ describe("reconcileSession", () => { 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 f237ca0..fc3d970 100644 --- a/src/reconcile.ts +++ b/src/reconcile.ts @@ -98,7 +98,7 @@ export async function seed(api: TuiPluginApi) { const group = list.slice(i, i + BATCH) const rowsBySession = await Promise.all( group.map(async (session) => ({ - rows: await fetchSessionRows(api, session.id), + rows: await fetchSessionRows(api, session.id).catch(() => []), })), ) rowsBySession.forEach(({ rows }) => {