Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
87 changes: 87 additions & 0 deletions src/agg.test.ts
Original file line number Diff line number Diff line change
@@ -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<Message> = {}) {
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)
})
})
99 changes: 16 additions & 83 deletions src/agg.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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"
}
Expand All @@ -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: {},
Expand All @@ -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 }
Expand All @@ -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") {
Expand All @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion src/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function Tabs<Value extends string>(props: {
api: TuiPluginApi
label: string
value: Value
list: Value[]
list: readonly Value[]
pick: (value: Value) => void
}) {
const theme = () => props.api.theme.current
Expand Down
58 changes: 49 additions & 9 deletions src/compute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -70,15 +86,9 @@ export function buildFlat(allowed: Set<string>, 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
Expand All @@ -105,6 +115,36 @@ export function activeSessions(allowed: Set<string>, 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)
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading