From 5b502321c0c8810036c7c8bff63d06d9f9b25ad5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Apr 2026 12:38:36 +0000 Subject: [PATCH 1/2] feat(tui): add /usage stats screen with Overview and Models subviews Adds a terminal-first stats screen reachable via /usage (alias /stats). The screen renders a GitHub-style year heatmap plus KPI block on the Overview tab, and a stacked per-day tokens chart with per-model breakdown on the Models tab. Date range (All time, Last 7 days, Last 30 days) cycles with `r` and filters all KPIs; the heatmap stays year-wide to match the mockup. ctrl+s copies a plain-text summary to the clipboard. Pure derivation logic lives in util/usage-stats.ts and is covered by unit tests. --- packages/opencode/src/cli/cmd/tui/app.tsx | 22 + .../src/cli/cmd/tui/context/route.tsx | 6 +- .../opencode/src/cli/cmd/tui/plugin/api.tsx | 9 + .../src/cli/cmd/tui/routes/stats/chart.tsx | 248 +++++++++ .../src/cli/cmd/tui/routes/stats/data.ts | 51 ++ .../src/cli/cmd/tui/routes/stats/heatmap.tsx | 131 +++++ .../src/cli/cmd/tui/routes/stats/index.tsx | 522 ++++++++++++++++++ .../src/cli/cmd/tui/routes/stats/palette.ts | 38 ++ .../src/cli/cmd/tui/routes/stats/summary.ts | 62 +++ .../src/cli/cmd/tui/util/usage-stats.ts | 436 +++++++++++++++ .../opencode/test/cli/tui/usage-stats.test.ts | 295 ++++++++++ 11 files changed, 1819 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/stats/chart.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/stats/data.ts create mode 100644 packages/opencode/src/cli/cmd/tui/routes/stats/heatmap.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx create mode 100644 packages/opencode/src/cli/cmd/tui/routes/stats/palette.ts create mode 100644 packages/opencode/src/cli/cmd/tui/routes/stats/summary.ts create mode 100644 packages/opencode/src/cli/cmd/tui/util/usage-stats.ts create mode 100644 packages/opencode/test/cli/tui/usage-stats.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 5da2740cce4c..fa689dffc4e9 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -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" @@ -319,6 +320,11 @@ function App(props: { onSnapshot?: () => Promise }) { if (route.data.type === "plugin") { renderer.setTerminalTitle(`OC | ${route.data.id}`) + return + } + + if (route.data.type === "stats") { + renderer.setTerminalTitle("OC | Stats") } }) @@ -587,6 +593,19 @@ function App(props: { onSnapshot?: () => Promise }) { }, 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", @@ -856,6 +875,9 @@ function App(props: { onSnapshot?: () => Promise }) { + + + {plugin()} diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 35be17801b1f..831fb648c248 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -19,7 +19,11 @@ export type PluginRoute = { data?: Record } -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", diff --git a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx index 5bea4838077b..530eca419956 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/api.tsx +++ b/packages/opencode/src/cli/cmd/tui/plugin/api.tsx @@ -81,6 +81,11 @@ function routeNavigate(route: ReturnType, name: string, params? return } + if (name === "stats") { + route.navigate({ type: "stats" }) + return + } + route.navigate({ type: "plugin", id: name, data: params }) } @@ -96,6 +101,10 @@ function routeCurrent(route: ReturnType): TuiPluginApi["route"] } } + if (route.data.type === "stats") { + return { name: "stats" } + } + return { name: route.data.id, params: route.data.data, diff --git a/packages/opencode/src/cli/cmd/tui/routes/stats/chart.tsx b/packages/opencode/src/cli/cmd/tui/routes/stats/chart.tsx new file mode 100644 index 000000000000..cd9bc90fae34 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/stats/chart.tsx @@ -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" + +/** 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 height = 10 + + 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 the row height ceiling the bar occupies. + const columns = createMemo(() => { + const max = maxDay() + return days().map((day) => { + // Rows: from top=0 (brightest) to bottom=height-1. For each row, we + // identify which model's bar occupies it, if any, for this 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(height).fill(undefined), total: 0 } + } + const totalRows = Math.max(1, Math.round((total / max) * height)) + const rows = Array(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 ( + + 0 && maxDay() > 0} + fallback={No model usage in this range.} + > + + {/* Y axis */} + + i)}> + {(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 ( + + + {tick().padStart(7)} + + + ) + }} + + + + {/* Plot area: render row-by-row, each row is a sequence of cells across days */} + + i)}> + {(rowIdx) => ( + + + {(col) => { + const model = col.rows[rowIdx] + if (!model) { + return ( + + + {" ".repeat(columnWidth())} + + + ) + } + const idx = modelList().indexOf(model) + const color = modelColor(model, idx < 0 ? 0 : idx) + const glyph = "█".repeat(columnWidth()) + return ( + + + {glyph} + + + ) + }} + + + )} + + + {/* X axis: show first, middle, last day labels only to avoid clutter */} + + + + + {/* Legend */} + + + {(model, i) => ( + + + ● + + {displayModel(model)} + + )} + + + + + ) +} + +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(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 ( + + + {labels()} + + + ) +} + +/** 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 +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/stats/data.ts b/packages/opencode/src/cli/cmd/tui/routes/stats/data.ts new file mode 100644 index 000000000000..38f7678187e7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/stats/data.ts @@ -0,0 +1,51 @@ +/** + * 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, + 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 assistant = msg + const input = assistant.tokens?.input ?? 0 + const output = assistant.tokens?.output ?? 0 + const reasoning = assistant.tokens?.reasoning ?? 0 + const cacheRead = assistant.tokens?.cache?.read ?? 0 + const cacheWrite = assistant.tokens?.cache?.write ?? 0 + const totalInput = input + cacheRead + cacheWrite + const totalOutput = output + reasoning + if (totalInput + totalOutput <= 0) continue + records.push({ + timestamp: assistant.time.created, + model: `${assistant.providerID}/${assistant.modelID}`, + input: totalInput, + output: totalOutput, + 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 +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/stats/heatmap.tsx b/packages/opencode/src/cli/cmd/tui/routes/stats/heatmap.tsx new file mode 100644 index 000000000000..d76746d79927 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/stats/heatmap.tsx @@ -0,0 +1,131 @@ +import { createMemo, For } from "solid-js" +import { RGBA } from "@opentui/core" +import { useTerminalDimensions } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { type UsageRecord, heatmapGrid, heatmapLevel } from "@tui/util/usage-stats" + +const ROW_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""] + +/** GitHub-style contribution heatmap. Cells are rendered as single double-cells + * (two spaces with background color) to preserve aspect ratio in a terminal. */ +export function Heatmap(props: { records: UsageRecord[] }) { + const { theme } = useTheme() + const dimensions = useTerminalDimensions() + + const weeksToShow = createMemo(() => { + // Leave ~10 cells of margin for the left labels/spacing. + const available = Math.max(10, Math.floor((dimensions().width - 14) / 3)) + return Math.min(53, Math.max(12, available)) + }) + + const grid = createMemo(() => heatmapGrid(props.records, Date.now(), weeksToShow())) + + const intensity = (level: number): RGBA => { + if (level === 0) return RGBA.fromInts(30, 30, 30) + const steps: [number, number, number][] = [ + [0, 0, 0], + [60, 40, 20], + [120, 80, 40], + [200, 130, 60], + [250, 178, 131], + ] + const [r, g, b] = steps[level] ?? steps[4] + return RGBA.fromInts(r, g, b) + } + + const cell = (value: number, max: number) => { + const level = heatmapLevel(value, max) + if (level === 0) { + return ( + + ·{" "} + + ) + } + return ( + + {" "} + + ) + } + + return ( + + {/* Month labels */} + + + {(char) => ( + + {char} + + )} + + + + {/* Rows: Sun..Sat. Only Mon/Wed/Fri are labeled to reduce clutter. */} + + {(row) => ( + + + + {ROW_LABELS[row]} + + + + + {(col) => { + const c = col[row] + return ( + + {c?.day ? cell(c.total, grid().max) : {" "}} + + ) + }} + + + + )} + + + {/* Legend */} + + Less + + {(level) => { + if (level === 0) + return ( + + ·{" "} + + ) + return ( + + + {" "} + + + ) + }} + + More + + + ) +} + +/** + * Given the week count and month labels, produce a string per column so + * that month names land at the right position in the grid. Each cell is + * 3 columns wide (2 block chars + 1 space separator). + */ +function buildMonthLine(weekCount: number, labels: { label: string; week: number }[]): string[] { + const width = 3 // per cell + const line = Array(weekCount * width).fill(" ") + for (const { label, week } of labels) { + const start = week * width + for (let i = 0; i < label.length && start + i < line.length; i++) { + line[start + i] = label[i] + } + } + return line +} diff --git a/packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx new file mode 100644 index 000000000000..b4df72fe5107 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/stats/index.tsx @@ -0,0 +1,522 @@ +import { batch, createMemo, For, onCleanup, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { RGBA, TextAttributes } from "@opentui/core" +import { useRoute } from "@tui/context/route" +import { useTheme } from "@tui/context/theme" +import { useSync } from "@tui/context/sync" +import { useSDK } from "@tui/context/sdk" +import { useToast } from "@tui/ui/toast" +import * as Clipboard from "@tui/util/clipboard" +import { + type DateRange, + DATE_RANGES, + filterByRange, + favoriteModel, + distinctSessions, + activeDays, + mostActiveDay, + longestSessionMs, + streaks, + totalTokens, + perModelShare, + formatCompact, + formatDuration, + formatPct, + formatShortDate, + compareToWork, + formatMultiplier, +} from "@tui/util/usage-stats" +import type { UsageRecord } from "@tui/util/usage-stats" +import { recordsFromMessages, displayModel } from "./data" +import { Heatmap } from "./heatmap" +import { Chart } from "./chart" +import { summarizeStats } from "./summary" +import { modelColor } from "./palette" + +type SubTab = "overview" | "models" + +const TAB_LABELS: { id: "status" | "config" | "usage" | "stats"; label: string }[] = [ + { id: "status", label: "Status" }, + { id: "config", label: "Config" }, + { id: "usage", label: "Usage" }, + { id: "stats", label: "Stats" }, +] + +export function Stats() { + const route = useRoute() + const { theme } = useTheme() + const sync = useSync() + const sdk = useSDK() + const toast = useToast() + const dimensions = useTerminalDimensions() + + const [store, setStore] = createStore<{ + range: DateRange + sub: SubTab + messages: Record + loading: boolean + loaded: number + total: number + }>({ + range: "all", + sub: "overview", + messages: {}, + loading: true, + loaded: 0, + total: 0, + }) + + // Mount: fetch the full session history (start=0) so All-time is accurate, + // then lazily load each session's messages in small parallel batches. + onMount(async () => { + let cancelled = false + onCleanup(() => { + cancelled = true + }) + + try { + const listResult = await sdk.client.session.list({ start: 0 }) + if (cancelled) return + const sessions = (listResult.data ?? []).filter((s) => !s.parentID) + setStore("total", sessions.length) + + const BATCH = 8 + for (let i = 0; i < sessions.length; i += BATCH) { + if (cancelled) return + const slice = sessions.slice(i, i + BATCH) + await Promise.all( + slice.map(async (session) => { + try { + // Prefer already-synced messages when available to avoid a refetch. + const existing = sync.data.message[session.id] + const messages = existing ?? (await fetchSessionMessages(sdk, session.id)) + if (cancelled) return + const records = recordsFromMessages(session, messages ?? []) + batch(() => { + setStore("messages", session.id, records) + setStore("loaded", (n) => n + 1) + }) + } catch { + batch(() => { + setStore("messages", session.id, []) + setStore("loaded", (n) => n + 1) + }) + } + }), + ) + } + } catch { + toast.show({ variant: "error", message: "Failed to load usage stats" }) + } finally { + if (!cancelled) setStore("loading", false) + } + }) + + const allRecords = createMemo(() => { + const out: UsageRecord[] = [] + for (const list of Object.values(store.messages)) out.push(...list) + return out + }) + + const filtered = createMemo(() => filterByRange(allRecords(), store.range)) + + const stats = createMemo(() => { + const recs = filtered() + const total = totalTokens(recs) + const days = activeDays(recs) + const s = streaks(recs) + return { + total, + favorite: favoriteModel(recs), + sessions: distinctSessions(recs), + active: days.length, + most: mostActiveDay(recs), + longest: longestSessionMs(recs), + longestStreak: s.longest, + currentStreak: s.current, + comparison: compareToWork(total), + perModel: perModelShare(recs), + } + }) + + const cycleRange = () => { + const idx = DATE_RANGES.findIndex((r) => r.id === store.range) + const next = DATE_RANGES[(idx + 1) % DATE_RANGES.length] + setStore("range", next.id) + } + + const copySummary = async () => { + const text = summarizeStats({ + range: store.range, + sub: store.sub, + records: filtered(), + }) + try { + await Clipboard.copy(text) + toast.show({ variant: "info", message: "Copied stats summary to clipboard" }) + } catch { + toast.show({ variant: "error", message: "Failed to copy to clipboard" }) + } + } + + useKeyboard((evt) => { + if (route.data.type !== "stats") return + if (evt.defaultPrevented) return + // Escape / ctrl+c returns to home + if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { + route.navigate({ type: "home" }) + evt.preventDefault() + return + } + if (evt.name === "q" && !evt.ctrl && !evt.meta) { + route.navigate({ type: "home" }) + evt.preventDefault() + return + } + if (evt.name === "r" && !evt.ctrl && !evt.meta) { + cycleRange() + evt.preventDefault() + return + } + if (evt.ctrl && evt.name === "s") { + void copySummary() + evt.preventDefault() + return + } + if (evt.name === "left" || evt.name === "h") { + setStore("sub", "overview") + evt.preventDefault() + return + } + if (evt.name === "right" || evt.name === "l") { + setStore("sub", "models") + evt.preventDefault() + return + } + if (evt.name === "tab" || evt.name === "up" || evt.name === "down") { + setStore("sub", store.sub === "overview" ? "models" : "overview") + evt.preventDefault() + } + }) + + const selectedFg = createMemo(() => selectedForeground(theme.primary)) + + return ( + + + setStore("sub", id)} + /> + + + + {(item) => { + const active = () => store.range === item.id + return ( + setStore("range", item.id)} + > + + {item.label} + + + ) + }} + + + + + 0} + fallback={} + > + 0} + fallback={} + > + + + + + + + + + + +