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
22 changes: 22 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { KeybindProvider, useKeybind } from "@tui/context/keybind"
import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { Stats } from "@tui/routes/stats"
import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
Expand Down Expand Up @@ -319,6 +320,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {

if (route.data.type === "plugin") {
renderer.setTerminalTitle(`OC | ${route.data.id}`)
return
}

if (route.data.type === "stats") {
renderer.setTerminalTitle("OC | Stats")
}
})

Expand Down Expand Up @@ -587,6 +593,19 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
},
category: "System",
},
{
title: "View usage stats",
value: "opencode.usage",
slash: {
name: "usage",
aliases: ["stats"],
},
onSelect: (dialog) => {
dialog.clear()
route.navigate({ type: "stats" })
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
Expand Down Expand Up @@ -856,6 +875,9 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
<Match when={route.data.type === "session"}>
<Session />
</Match>
<Match when={route.data.type === "stats"}>
<Stats />
</Match>
</Switch>
</Show>
{plugin()}
Expand Down
6 changes: 5 additions & 1 deletion packages/opencode/src/cli/cmd/tui/context/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,11 @@ export type PluginRoute = {
data?: Record<string, unknown>
}

export type Route = HomeRoute | SessionRoute | PluginRoute
export type StatsRoute = {
type: "stats"
}

export type Route = HomeRoute | SessionRoute | PluginRoute | StatsRoute

export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/cmd/tui/plugin/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ function routeNavigate(route: ReturnType<typeof useRoute>, name: string, params?
return
}

if (name === "stats") {
route.navigate({ type: "stats" })
return
}

route.navigate({ type: "plugin", id: name, data: params })
}

Expand All @@ -96,6 +101,10 @@ function routeCurrent(route: ReturnType<typeof useRoute>): TuiPluginApi["route"]
}
}

if (route.data.type === "stats") {
return { name: "stats" }
}

return {
name: route.data.id,
params: route.data.data,
Expand Down
248 changes: 248 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/stats/chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { createMemo, For, Show } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useTerminalDimensions } from "@opentui/solid"
import {
type DateRange,
type UsageRecord,
dayKey,
formatCompact,
formatShortDate,
rangeBounds,
tokensPerModelPerDay,
} from "@tui/util/usage-stats"
import { modelColor } from "./palette"
import { displayModel } from "./data"

const HEIGHT = 10
const ROW_INDICES = Array.from({ length: HEIGHT }, (_, i) => i)

/** A terminal-friendly stacked vertical-bar chart.
*
* Each column is a day; within a column we split the bar proportionally
* by model share. The Y axis shows compact token tick labels. */
export function Chart(props: { records: UsageRecord[]; range: DateRange }) {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()

const days = createMemo(() => buildDaySpine(props.records, props.range))
const perModelDay = createMemo(() => tokensPerModelPerDay(props.records))
const modelList = createMemo(() =>
[...perModelDay()]
.map(([model, series]) => ({ model, total: series.reduce((s, d) => s + d.total, 0) }))
.sort((a, b) => b.total - a.total)
.map((x) => x.model),
)

const maxDay = createMemo(() => {
let max = 0
for (const day of days()) {
let total = 0
for (const [, series] of perModelDay()) {
const match = series.find((s) => s.day === day)
if (match) total += match.total
}
if (total > max) max = total
}
return max
})

// Width budget: 6 cols for Y axis label, rest split among day columns.
const columnWidth = createMemo(() => {
const available = Math.max(20, dimensions().width - 10 - 2 * 2)
const count = Math.max(1, days().length)
return Math.max(1, Math.min(3, Math.floor(available / count)))
})

// For every day + model, compute which model (if any) occupies each row
// of the stacked bar, from bottom up.
const columns = createMemo(() => {
const max = maxDay()
return days().map((day) => {
const segments: { model: string; count: number }[] = []
let total = 0
for (const model of modelList()) {
const series = perModelDay().get(model)
const match = series?.find((s) => s.day === day)
if (!match) continue
total += match.total
segments.push({ model, count: match.total })
}
if (max <= 0 || total <= 0) {
return { day, rows: Array<string | undefined>(HEIGHT).fill(undefined), total: 0 }
}
const totalRows = Math.max(1, Math.round((total / max) * HEIGHT))
const rows = Array<string | undefined>(HEIGHT).fill(undefined)
let filled = 0
// Fill from bottom up: walk segments in order, assigning rows proportionally.
for (let i = 0; i < segments.length; i++) {
const seg = segments[i]
const segRows =
i === segments.length - 1
? totalRows - filled
: Math.max(0, Math.round((seg.count / total) * totalRows))
for (let r = 0; r < segRows; r++) {
const rowIndex = HEIGHT - 1 - (filled + r)
if (rowIndex >= 0) rows[rowIndex] = seg.model
}
filled += segRows
}
return { day, rows, total }
})
})

return (
<box flexDirection="column">
<Show
when={props.records.length > 0 && maxDay() > 0}
fallback={<text fg={theme.textMuted}>No model usage in this range.</text>}
>
<box flexDirection="row">
{/* Y axis */}
<box flexShrink={0} width={8} flexDirection="column">
<For each={ROW_INDICES}>
{(rowIdx) => {
const tick = () => {
const max = maxDay()
const isFirst = rowIdx === 0
const isMid = rowIdx === Math.floor(HEIGHT / 2)
const isLast = rowIdx === HEIGHT - 1
if (isFirst) return formatCompact(max)
if (isMid) return formatCompact(Math.round(max / 2))
if (isLast) return "0"
return ""
}
return (
<box flexDirection="row" justifyContent="flex-end" width={7}>
<text fg={theme.textMuted} selectable={false}>
{tick().padStart(7)}
</text>
</box>
)
}}
</For>
</box>

{/* Plot area: render row-by-row, each row is a sequence of cells across days */}
<box flexDirection="column">
<For each={ROW_INDICES}>
{(rowIdx) => (
<box flexDirection="row">
<For each={columns()}>
{(col) => {
const model = col.rows[rowIdx]
if (!model) {
return (
<box width={columnWidth()}>
<text fg={theme.textMuted} selectable={false}>
{" ".repeat(columnWidth())}
</text>
</box>
)
}
const idx = modelList().indexOf(model)
const color = modelColor(model, idx < 0 ? 0 : idx)
const glyph = "█".repeat(columnWidth())
return (
<box width={columnWidth()}>
<text fg={color} selectable={false}>
{glyph}
</text>
</box>
)
}}
</For>
</box>
)}
</For>

{/* X axis: show first, middle, last day labels only to avoid clutter */}
<XAxis days={days()} columnWidth={columnWidth()} />
</box>
</box>

{/* Legend */}
<box flexDirection="row" paddingLeft={8} paddingTop={1} gap={2}>
<For each={modelList()}>
{(model, i) => (
<box flexDirection="row" gap={1}>
<text fg={modelColor(model, i())} selectable={false}>
</text>
<text fg={theme.text}>{displayModel(model)}</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}

function XAxis(props: { days: string[]; columnWidth: number }) {
const { theme } = useTheme()
const width = () => Math.max(1, props.days.length * props.columnWidth)
const labels = createMemo(() => {
if (props.days.length === 0) return ""
const line = Array<string>(width()).fill(" ")
const place = (idx: number, text: string) => {
const center = idx * props.columnWidth
const start = Math.max(0, Math.min(line.length - text.length, center - Math.floor(text.length / 2)))
for (let i = 0; i < text.length; i++) line[start + i] = text[i]
}
if (props.days.length === 1) {
place(0, formatShortDate(props.days[0]))
return line.join("")
}
place(0, formatShortDate(props.days[0]))
const mid = Math.floor(props.days.length / 2)
place(mid, formatShortDate(props.days[mid]))
place(props.days.length - 1, formatShortDate(props.days[props.days.length - 1]))
return line.join("")
})
return (
<box flexDirection="row">
<text fg={theme.textMuted} selectable={false}>
{labels()}
</text>
</box>
)
}

/** Build the full list of days in the selected range, including zero-usage days. */
function buildDaySpine(records: UsageRecord[], range: DateRange): string[] {
if (range === "all") {
// For all-time, anchor the spine to the records' span; if empty, return [].
if (records.length === 0) return []
let min = records[0].timestamp
let max = records[0].timestamp
for (const r of records) {
if (r.timestamp < min) min = r.timestamp
if (r.timestamp > max) max = r.timestamp
}
// Cap to a sane horizon (last 60 days anchored to latest record) to avoid
// a gigantic chart when the full history stretches back years.
const earliestAllowed = max - 60 * 24 * 60 * 60 * 1000
if (min < earliestAllowed) min = earliestAllowed
return daySpan(min, max)
}
const bounds = rangeBounds(range)
if (!bounds) return []
return daySpan(bounds.start, bounds.end)
}

function daySpan(startMs: number, endMs: number): string[] {
const days: string[] = []
const start = new Date(startMs)
start.setHours(0, 0, 0, 0)
const end = new Date(endMs)
end.setHours(0, 0, 0, 0)
const endTs = end.getTime()
let curTs = start.getTime()
while (curTs <= endTs) {
days.push(dayKey(curTs))
const d = new Date(curTs)
d.setDate(d.getDate() + 1)
curTs = d.getTime()
}
return days
}
45 changes: 45 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/stats/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/**
* Extracts `UsageRecord`s from the sync store (sessions + messages already
* loaded in memory) and, when missing data is needed, fetches it lazily.
*/
import type { Message, Session } from "@opencode-ai/sdk/v2"
import type { UsageRecord } from "@tui/util/usage-stats"

export function recordsFromMessages(
session: Pick<Session, "id" | "time">,
messages: readonly Message[],
): UsageRecord[] {
const records: UsageRecord[] = []
let sessionStart = session.time.created
let sessionEnd = session.time.updated
for (const msg of messages) {
if (msg.time?.created && msg.time.created < sessionStart) sessionStart = msg.time.created
if (msg.role === "assistant" && msg.time.completed && msg.time.completed > sessionEnd) {
sessionEnd = msg.time.completed
}
}
for (const msg of messages) {
if (msg.role !== "assistant") continue
const input = (msg.tokens?.input ?? 0) + (msg.tokens?.cache?.read ?? 0) + (msg.tokens?.cache?.write ?? 0)
const output = (msg.tokens?.output ?? 0) + (msg.tokens?.reasoning ?? 0)
if (input + output <= 0) continue
records.push({
timestamp: msg.time.created,
model: `${msg.providerID}/${msg.modelID}`,
input,
output,
sessionID: session.id,
sessionStart,
sessionEnd,
})
}
return records
}

/** Compact display label for a model id like "anthropic/claude-opus-4-7". */
export function displayModel(fullId: string): string {
const slash = fullId.lastIndexOf("/")
const raw = slash >= 0 ? fullId.slice(slash + 1) : fullId
const cleaned = raw.replace(/^claude-/, "").replace(/-\d{8}$/, "")
return cleaned
}
Loading