mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): add shareable stats poster (#46563)
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { ClientError, OpenCode, type SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
|
||||
import { Effect, Option } from "effect"
|
||||
import { EOL } from "node:os"
|
||||
import { Commands } from "../commands"
|
||||
@@ -91,7 +93,7 @@ type RenderOptions = {
|
||||
const colors = terminalPalette()
|
||||
|
||||
export function renderStats(stats: SessionStatsInfo, options: RenderOptions) {
|
||||
const totalTokens = tokenTotal(stats.tokens)
|
||||
const totalTokens = TokenUsage.total(stats.tokens)
|
||||
const toolTotals = stats.tools.mode === "none" ? undefined : stats.tools.totals
|
||||
const terminalTools = toolTotals ? toolTotals.succeeded + toolTotals.failed : 0
|
||||
const toolRate = !toolTotals || terminalTools === 0 ? undefined : (toolTotals.succeeded / terminalTools) * 100
|
||||
@@ -167,43 +169,23 @@ function renderActivity(
|
||||
color: boolean,
|
||||
width: number,
|
||||
) {
|
||||
const values = new Map(activity.map((day) => [day.date, day.steps]))
|
||||
const rangeStart = dateOrdinal(new Date(from))
|
||||
const rangeEnd = dateOrdinal(new Date(to - 1))
|
||||
const end = new Date(to - 1)
|
||||
end.setHours(12, 0, 0, 0)
|
||||
end.setDate(end.getDate() + (7 - mondayIndex(end) - 1))
|
||||
const start = new Date(from)
|
||||
start.setHours(12, 0, 0, 0)
|
||||
start.setDate(start.getDate() - mondayIndex(start))
|
||||
const maxWeeks = Math.max(1, Math.min(53, width - 4))
|
||||
const totalWeeks = Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1
|
||||
const latest = new Date(end)
|
||||
latest.setDate(latest.getDate() - (maxWeeks - 1) * 7)
|
||||
if (start < latest) start.setTime(latest.getTime())
|
||||
|
||||
const active = [...values.values()].filter((value) => value > 0)
|
||||
const levels = [...new Set(active)].sort((a, b) => a - b)
|
||||
const weekStarts = Array.from({ length: Math.floor((dateOrdinal(end) - dateOrdinal(start)) / 7) + 1 }, (_, week) => {
|
||||
const date = new Date(start)
|
||||
date.setDate(date.getDate() + week * 7)
|
||||
return date
|
||||
})
|
||||
const weeks = weekStarts.map((week) =>
|
||||
Array.from({ length: 7 }, (_, day) => {
|
||||
const date = new Date(week)
|
||||
date.setDate(date.getDate() + day)
|
||||
const ordinal = dateOrdinal(date)
|
||||
if (ordinal < rangeStart || ordinal > rangeEnd) return " "
|
||||
return activityGlyph(values.get(dateKey(date)) ?? 0, levels, color)
|
||||
}),
|
||||
)
|
||||
const calendar = activityCalendar({ activity, from, to, maxWeeks: width - 4 })
|
||||
const months = calendar.months
|
||||
.map((month, index) =>
|
||||
(index === calendar.months.length - 1 || month.label.length <= month.span ? month.label : "").padEnd(month.span),
|
||||
)
|
||||
.join("")
|
||||
.trimEnd()
|
||||
const weekdays = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]
|
||||
return [
|
||||
style(totalWeeks > maxWeeks ? `activity · last ${maxWeeks} weeks` : "activity", `1;${colors.primary}`, color),
|
||||
` ${style(monthLabels(weekStarts), "2", color)}`,
|
||||
style(
|
||||
calendar.clipped ? `activity · last ${calendar.weeks.length} weeks` : "activity",
|
||||
`1;${colors.primary}`,
|
||||
color,
|
||||
),
|
||||
` ${style(months, "2", color)}`,
|
||||
...weekdays.flatMap((label, day) => [
|
||||
`${style(label, "2", color)} ${weeks.map((week) => week[day]).join("")}`,
|
||||
`${style(label, "2", color)} ${calendar.weeks.map((week) => (week[day].level < 0 ? " " : paintActivity(week[day].level, color))).join("")}`,
|
||||
...(day === weekdays.length - 1 ? [] : [""]),
|
||||
]),
|
||||
"",
|
||||
@@ -238,7 +220,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
width,
|
||||
),
|
||||
` ${formatNumber(tokenTotal(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
|
||||
` ${formatNumber(TokenUsage.total(item.tokens))} tokens · ${formatNumber(item.steps)} steps · $${item.cost.toFixed(2)}`,
|
||||
]),
|
||||
...(more > 0 ? ["", `+${more.toLocaleString("en-US")} more model${more === 1 ? "" : "s"}`] : []),
|
||||
]
|
||||
@@ -248,7 +230,7 @@ function renderModels(stats: SessionStatsInfo, limit: number, width: number) {
|
||||
...models.map((item) =>
|
||||
tableRow(
|
||||
`${item.model.providerID}/${item.model.id}${item.model.variant ? `#${item.model.variant}` : ""}`,
|
||||
formatNumber(tokenTotal(item.tokens)),
|
||||
formatNumber(TokenUsage.total(item.tokens)),
|
||||
formatNumber(item.steps),
|
||||
`$${item.cost.toFixed(2)}`,
|
||||
),
|
||||
@@ -310,10 +292,6 @@ function truncate(value: string, width: number) {
|
||||
return value.length <= width ? value : value.slice(0, width - 1) + "…"
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: SessionStatsInfo["tokens"]) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
function formatNumber(value: number) {
|
||||
if (value >= 1_000_000_000) return `${trimDecimal(value / 1_000_000_000)}b`
|
||||
if (value >= 1_000_000) return `${trimDecimal(value / 1_000_000)}m`
|
||||
@@ -342,13 +320,6 @@ function style(value: string, code: string, color: boolean) {
|
||||
return color ? `\x1b[${code}m${value}\x1b[0m` : value
|
||||
}
|
||||
|
||||
function activityGlyph(value: number, levels: number[], color: boolean) {
|
||||
if (value === 0) return paintActivity(0, color)
|
||||
const index = levels.indexOf(value)
|
||||
const level = Math.max(1, Math.ceil(((index + 1) / levels.length) * 4))
|
||||
return paintActivity(level, color)
|
||||
}
|
||||
|
||||
function paintActivity(level: number, color: boolean) {
|
||||
const glyph = ["·", "░", "▒", "▓", "█"][level]
|
||||
if (!color) return glyph
|
||||
@@ -370,39 +341,3 @@ function terminalPalette() {
|
||||
}
|
||||
return { primary: "36", activity: ["2;36", "36", "1;36", "1;96"] }
|
||||
}
|
||||
|
||||
function monthLabels(weeks: Date[]) {
|
||||
const line: string[] = []
|
||||
weeks.reduce((previous, week, index) => {
|
||||
const middle = new Date(week)
|
||||
middle.setDate(middle.getDate() + 3)
|
||||
const month = middle.getMonth()
|
||||
if (month === previous) return previous
|
||||
Intl.DateTimeFormat("en-US", { month: "short" })
|
||||
.format(middle)
|
||||
.split("")
|
||||
.forEach((character, offset) => {
|
||||
line[index + offset] = character
|
||||
})
|
||||
return month
|
||||
}, -1)
|
||||
return Array.from({ length: Math.max(weeks.length, line.length) }, (_, index) => line[index] ?? " ")
|
||||
.join("")
|
||||
.trimEnd()
|
||||
}
|
||||
|
||||
function mondayIndex(date: Date) {
|
||||
return (date.getDay() + 6) % 7
|
||||
}
|
||||
|
||||
function dateKey(date: Date) {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, "0"),
|
||||
String(date.getDate()).padStart(2, "0"),
|
||||
].join("-")
|
||||
}
|
||||
|
||||
function dateOrdinal(date: Date) {
|
||||
return Math.floor(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,20 @@ describe("stats rendering", () => {
|
||||
expect(output).not.toContain("$12.34")
|
||||
})
|
||||
|
||||
test("renders shared intensity levels and partial month labels", () => {
|
||||
const output = renderStats(
|
||||
{
|
||||
...stats,
|
||||
range: { from: new Date(2026, 3, 29).getTime(), to: new Date(2026, 4, 20).getTime() },
|
||||
activity: [{ date: "2026-04-29", steps: 1 }],
|
||||
},
|
||||
options(),
|
||||
)
|
||||
expect(output).toContain(" May")
|
||||
expect(output).not.toContain("Apr")
|
||||
expect(output.split(/\r?\n/).find((line) => line.startsWith("We "))).toContain("\u2588")
|
||||
})
|
||||
|
||||
test("renders only requested detail tables", () => {
|
||||
const output = renderStats(stats, options({ tools: true, cost: true }))
|
||||
expect(output).toContain("COST & TOKENS")
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Project } from "@opencode-ai/schema/project"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { SessionEvent } from "@opencode-ai/schema/session-event"
|
||||
import { ToolMode } from "@opencode-ai/schema/session-stats"
|
||||
import { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
import { Database } from "../database/database.js"
|
||||
import { EventTable } from "../event/sql.js"
|
||||
import { SessionMessageTable, SessionTable } from "./sql.js"
|
||||
@@ -341,7 +342,7 @@ export const get = Effect.fn("SessionStats.get")(function* (input: Input = {}) {
|
||||
streak: longestStreak(days.map(([date]) => date)),
|
||||
activity: days.map(([date, steps]) => ({ date, steps })),
|
||||
models: [...models.values()]
|
||||
.sort((a, b) => tokenTotal(b.tokens) - tokenTotal(a.tokens))
|
||||
.sort((a, b) => TokenUsage.total(b.tokens) - TokenUsage.total(a.tokens))
|
||||
.map((model) => ({ ...model, cost: Money.USD.make(model.cost) })),
|
||||
}
|
||||
})
|
||||
@@ -378,10 +379,6 @@ function addTokens(target: Tokens, source: Tokens) {
|
||||
target.cache.write += source.cache.write
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: Tokens) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
function addToolStatus(
|
||||
target: { calls: number; succeeded: number; failed: number; unfinished: number },
|
||||
status: string | null,
|
||||
|
||||
@@ -12,3 +12,7 @@ export const Info = Schema.Struct({
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
}).annotate({ identifier: "TokenUsage.Info" })
|
||||
|
||||
export function total(tokens: Info) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { TokenUsage } from "../src/token-usage.js"
|
||||
|
||||
test("totals every token category", () => {
|
||||
expect(TokenUsage.total({ input: 1, output: 2, reasoning: 4, cache: { read: 8, write: 16 } })).toBe(31)
|
||||
})
|
||||
|
||||
test("supports zero and large usage totals without rounding", () => {
|
||||
expect(TokenUsage.total({ input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } })).toBe(0)
|
||||
expect(
|
||||
TokenUsage.total({
|
||||
input: 1_000_000_000,
|
||||
output: 2_000_000_000,
|
||||
reasoning: 3,
|
||||
cache: { read: 4_000_000_000, write: 5 },
|
||||
}),
|
||||
).toBe(7_000_000_008)
|
||||
})
|
||||
@@ -1321,7 +1321,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
<PaneResizeHandle resize={tabsResize} left={tabsResize.size() - 1} />
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={devtools()}>
|
||||
<Show when={devtools() && !(route.data.type === "plugin" && route.data.id === "opencode.stats")}>
|
||||
<DevToolsBar />
|
||||
</Show>
|
||||
<Show when={!startup.skipInitialLoading}>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { TokenUsage } from "@opencode-ai/schema/token-usage"
|
||||
|
||||
export function statsMetrics(stats: SessionStatsInfo) {
|
||||
return [
|
||||
{
|
||||
label: "tokens",
|
||||
value: TokenUsage.total(stats.tokens),
|
||||
},
|
||||
{ label: "best streak", value: stats.streak },
|
||||
{ label: "active days", value: stats.activeDays },
|
||||
{ label: "sessions", value: stats.sessions },
|
||||
]
|
||||
}
|
||||
|
||||
export function statsNumber(value: number) {
|
||||
return new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { activityCalendar } from "@opencode-ai/util/activity-calendar"
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createMemo, createResource, createSignal, For, Show } from "solid-js"
|
||||
import { Logo } from "../../component/logo"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { tint } from "../../theme/color"
|
||||
import { statsMetrics, statsNumber } from "./stats-data"
|
||||
|
||||
const digits: Record<string, string[]> = {
|
||||
"0": ["111", "101", "101", "101", "111"],
|
||||
"1": ["010", "110", "010", "010", "111"],
|
||||
"2": ["111", "001", "111", "100", "111"],
|
||||
"3": ["111", "001", "111", "001", "111"],
|
||||
"4": ["101", "101", "111", "001", "001"],
|
||||
"5": ["111", "100", "111", "001", "111"],
|
||||
"6": ["111", "100", "111", "101", "111"],
|
||||
"7": ["111", "001", "010", "010", "010"],
|
||||
"8": ["111", "101", "111", "101", "111"],
|
||||
"9": ["111", "101", "111", "001", "111"],
|
||||
".": ["0", "0", "0", "0", "1"],
|
||||
K: ["101", "110", "100", "110", "101"],
|
||||
M: ["10001", "11011", "10101", "10001", "10001"],
|
||||
B: ["110", "101", "110", "101", "110"],
|
||||
T: ["111", "010", "010", "010", "010"],
|
||||
}
|
||||
|
||||
export function StatsPoster(props: { stats: SessionStatsInfo }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme()
|
||||
const themes = useThemes()
|
||||
const width = () => Math.max(12, Math.min(110, dimensions().width - 8))
|
||||
const compact = () => dimensions().height < 38
|
||||
const metrics = createMemo(() => statsMetrics(props.stats))
|
||||
const number = () => statsNumber(metrics()[0].value)
|
||||
const dates = createMemo(() =>
|
||||
new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric" }).formatRange(
|
||||
new Date(props.stats.range.from),
|
||||
new Date(Math.max(props.stats.range.from, props.stats.range.to - 1)),
|
||||
),
|
||||
)
|
||||
const letters = createMemo(() => Array.from(number()).map((char) => digits[char]))
|
||||
const large = () =>
|
||||
letters().every(Boolean) && letters().reduce((sum, char) => sum + char[0].length * 2 + 2, -2) <= width()
|
||||
const calendar = createMemo(() =>
|
||||
activityCalendar({
|
||||
activity: props.stats.activity,
|
||||
from: props.stats.range.from,
|
||||
to: props.stats.range.to,
|
||||
maxWeeks: Math.floor((width() - 4) / 2),
|
||||
}),
|
||||
)
|
||||
const shades = createMemo(() => [
|
||||
theme.text.subdued,
|
||||
...[0.3, 0.5, 0.75, 1].map((alpha) =>
|
||||
tint(theme.background.default, theme.categorical[0][themes.mode() === "light" ? 800 : 200], alpha),
|
||||
),
|
||||
])
|
||||
|
||||
return (
|
||||
<box width={width()} flexDirection="column" alignItems="center" flexShrink={0} gap={compact() ? 1 : 2}>
|
||||
<box width="100%" flexDirection={width() < 44 ? "column" : "row"} justifyContent="space-between">
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
opencode / stats
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{dates()}</text>
|
||||
</box>
|
||||
<Show when={!compact()}>
|
||||
<Logo />
|
||||
</Show>
|
||||
<box alignItems="center" gap={1}>
|
||||
<Show
|
||||
when={large()}
|
||||
fallback={
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
{number()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box>
|
||||
<For each={[0, 1, 2, 3, 4]}>
|
||||
{(row) => (
|
||||
<text fg={theme.text.default} selectable={false}>
|
||||
{letters()
|
||||
.map((char) => char[row].replaceAll("1", "\u2588\u2588").replaceAll("0", " "))
|
||||
.join(" ")}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.text.subdued}>TOKENS</text>
|
||||
</box>
|
||||
<box alignItems="center">
|
||||
<text fg={theme.text.subdued}>
|
||||
{" " +
|
||||
calendar()
|
||||
.months.map((month) => (month.label.length <= month.span * 2 ? month.label : "").padEnd(month.span * 2))
|
||||
.join("")}
|
||||
</text>
|
||||
<For each={["M", "T", "W", "T", "F", "S", "S"]}>
|
||||
{(day, index) => (
|
||||
<box flexDirection="row" height={1}>
|
||||
<text fg={theme.text.subdued}>{day + " "}</text>
|
||||
<For each={calendar().weeks}>
|
||||
{(week) => (
|
||||
<text fg={shades()[Math.max(0, week[index()].level)]} selectable={false}>
|
||||
{week[index()].level < 0 ? " " : week[index()].level === 0 ? "\u00b7 " : "\u25a0 "}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<Show when={calendar().clipped}>
|
||||
<text fg={theme.text.subdued}>Your last {calendar().weeks.length} weeks</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="space-around">
|
||||
<For each={metrics().slice(1)}>
|
||||
{(metric) => (
|
||||
<box alignItems="center">
|
||||
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
|
||||
{statsNumber(metric.value)}
|
||||
{metric.label === "best streak" ? " days" : ""}
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>{metric.label}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box width="100%" flexDirection="row" justifyContent="flex-end">
|
||||
<text fg={theme.text.default}>opencode.ai</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsPage(props: { context: Plugin.Context; onClose: () => void }) {
|
||||
const [result] = createResource(() => {
|
||||
const now = new Date()
|
||||
return props.context.client.session.stats({
|
||||
from: new Date(now.getFullYear(), 0, 1).getTime(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC",
|
||||
tools: "none",
|
||||
})
|
||||
})
|
||||
const theme = useTheme()
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [{ bind: "escape", title: "back", run: props.onClose }],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" backgroundColor={theme.background.default}>
|
||||
<scrollbox
|
||||
flexGrow={1}
|
||||
contentOptions={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100%",
|
||||
paddingTop: 1,
|
||||
paddingBottom: 1,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={!result.error}
|
||||
fallback={
|
||||
<text fg={theme.text.feedback.error.default}>Could not load stats. Reopen /stats to try again.</text>
|
||||
}
|
||||
>
|
||||
<Show when={result()} fallback={<text fg={theme.text.subdued}>Gathering your stats...</text>}>
|
||||
{(value) => <StatsPoster stats={value()} />}
|
||||
</Show>
|
||||
</Show>
|
||||
</scrollbox>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.stats",
|
||||
setup(context) {
|
||||
const [previous, setPrevious] = createSignal({ ...context.ui.router.current() })
|
||||
context.ui.router.register({
|
||||
name: "stats",
|
||||
render: () => <StatsPage context={context} onClose={() => context.ui.router.navigate(previous())} />,
|
||||
})
|
||||
context.ui.slot({
|
||||
append: "app",
|
||||
render() {
|
||||
context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "stats.open",
|
||||
title: "Usage statistics",
|
||||
group: "System",
|
||||
slash: { name: "stats" },
|
||||
palette: true,
|
||||
run() {
|
||||
const current = context.ui.router.current()
|
||||
if (current.type === "plugin" && current.name === "stats") return
|
||||
// The router exposes a mutable store; retain the route before navigating.
|
||||
setPrevious({ ...current })
|
||||
context.ui.dialog.clear()
|
||||
context.ui.router.navigate({ type: "plugin", name: "stats" })
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return null
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import Plugins from "../feature-plugins/system/plugins"
|
||||
import Storybook from "../feature-plugins/system/storybook"
|
||||
import Stats from "../feature-plugins/system/stats"
|
||||
import Latex from "@opencode-ai/latex/plugin"
|
||||
import Merman from "@opencode-ai/merman/plugin"
|
||||
|
||||
@@ -18,6 +19,7 @@ export const builtins = [
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
Stats,
|
||||
Merman,
|
||||
Latex,
|
||||
// The storybook is a development tool; keep its route and palette commands out of
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionStatsInfo } from "@opencode-ai/client"
|
||||
import { statsMetrics, statsNumber } from "../src/feature-plugins/system/stats-data"
|
||||
|
||||
const stats: SessionStatsInfo = {
|
||||
range: { from: new Date(2026, 0, 1).getTime(), to: new Date(2026, 0, 8).getTime() },
|
||||
sessions: 12,
|
||||
subagents: 99,
|
||||
prompts: 24,
|
||||
steps: 50,
|
||||
activeDays: 2,
|
||||
streak: 2,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
cost: 0,
|
||||
tools: { mode: "none" },
|
||||
models: [],
|
||||
activity: [
|
||||
{ date: "2026-01-01", steps: 1 },
|
||||
{ date: "2026-01-02", steps: 50 },
|
||||
],
|
||||
}
|
||||
|
||||
describe("stats poster", () => {
|
||||
test("includes all token categories and replaces subagents with tokens", () => {
|
||||
expect(statsMetrics(stats)).toEqual([
|
||||
{ label: "tokens", value: 15 },
|
||||
{ label: "best streak", value: 2 },
|
||||
{ label: "active days", value: 2 },
|
||||
{ label: "sessions", value: 12 },
|
||||
])
|
||||
expect([0, 685, 5284, 9_200_000_000].map(statsNumber)).toEqual(["0", "685", "5.3K", "9.2B"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createTestRenderer } from "@opentui/core/testing"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { createEventStream, createFetch, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("stats shows only this year and returns after errors or success", async () => {
|
||||
await using state = await tmpdir()
|
||||
const setup = await createTestRenderer({ width: 100, height: 34, useThread: false, kittyKeyboard: true })
|
||||
setup.renderer.start()
|
||||
const requests: URL[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/session/stats") return undefined
|
||||
requests.push(url)
|
||||
if (requests.length === 1) return json({ message: "offline" }, { status: 503 })
|
||||
return json({
|
||||
data: {
|
||||
range: { from: new Date(new Date().getFullYear(), 0, 1).getTime(), to: Date.now() },
|
||||
sessions: 123,
|
||||
subagents: 0,
|
||||
prompts: 1,
|
||||
steps: 1,
|
||||
tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 20, write: 0 } },
|
||||
cost: 0,
|
||||
tools: { mode: "none" },
|
||||
activeDays: 1,
|
||||
streak: 1,
|
||||
activity: [{ date: `${new Date().getFullYear()}-01-01`, steps: 1 }],
|
||||
models: [],
|
||||
},
|
||||
})
|
||||
}, createEventStream())
|
||||
const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) })
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: {
|
||||
get: async () => ({ animations: false, tabs: { enabled: false } }),
|
||||
update: async () => ({}),
|
||||
},
|
||||
packages: { resolve: async () => undefined },
|
||||
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: () => {} }),
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
try {
|
||||
await setup.waitForFrame((frame) => frame.includes("commands"))
|
||||
await setup.mockInput.typeText("/stats")
|
||||
await setup.waitForFrame((frame) => frame.includes("Usage statistics"))
|
||||
setup.mockInput.pressKey("RETURN")
|
||||
await setup.waitForFrame((frame) => frame.includes("Could not load stats"))
|
||||
setup.mockInput.pressKey("ESCAPE")
|
||||
await setup.waitForFrame((frame) => frame.includes("commands") && !frame.includes("Could not load stats"))
|
||||
await setup.mockInput.typeText("/stats")
|
||||
await setup.waitForFrame((frame) => frame.includes("Usage statistics"))
|
||||
setup.mockInput.pressKey("RETURN")
|
||||
await setup.waitForFrame((frame) => frame.includes("TOKENS") && frame.includes("123"))
|
||||
expect(requests[1].searchParams.get("tools")).toBe("none")
|
||||
expect(Number(requests[1].searchParams.get("from"))).toBe(new Date(new Date().getFullYear(), 0, 1).getTime())
|
||||
expect(requests[1].searchParams.has("to")).toBe(false)
|
||||
expect(setup.captureCharFrame()).not.toContain("all time")
|
||||
expect(setup.captureCharFrame()).not.toContain("All projects")
|
||||
expect(setup.captureCharFrame()).not.toContain("show this year")
|
||||
expect(setup.captureCharFrame()).not.toContain("esc")
|
||||
expect(
|
||||
setup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes("opencode / stats")),
|
||||
).not.toContain("tab")
|
||||
setup.mockInput.pressKey("RETURN")
|
||||
setup.mockInput.pressKey("SPACE")
|
||||
await setup.mockInput.typeText("rhp")
|
||||
setup.mockInput.pressKey("TAB")
|
||||
setup.mockInput.pressKey("RIGHT")
|
||||
await setup.waitForVisualIdle()
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(setup.captureCharFrame()).toContain("TOKENS")
|
||||
expect(setup.captureCharFrame()).not.toContain("headline")
|
||||
setup.mockInput.pressKey("ESCAPE")
|
||||
await setup.waitForFrame((frame) => frame.includes("commands") && !frame.includes("opencode / stats"))
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await task.finally(() => server.stop(true))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { activityCalendar } from "./activity-calendar.js"
|
||||
|
||||
const input = {
|
||||
from: new Date(2026, 0, 1).getTime(),
|
||||
to: new Date(2026, 0, 8).getTime(),
|
||||
maxWeeks: 53,
|
||||
activity: [
|
||||
{ date: "2026-01-01", steps: 1 },
|
||||
{ date: "2026-01-02", steps: 50 },
|
||||
],
|
||||
}
|
||||
|
||||
describe("activity calendar", () => {
|
||||
test("aligns Monday-first weeks and excludes cells outside the end-exclusive range", () => {
|
||||
const calendar = activityCalendar(input)
|
||||
expect(calendar.clipped).toBe(false)
|
||||
expect(calendar.weeks).toHaveLength(2)
|
||||
expect(calendar.weeks[0][0]).toEqual({ date: "2025-12-29", steps: 0, level: -1 })
|
||||
expect(calendar.weeks[0][3]).toEqual({ date: "2026-01-01", steps: 1, level: 2 })
|
||||
expect(calendar.weeks[0][4]).toEqual({ date: "2026-01-02", steps: 50, level: 4 })
|
||||
expect(calendar.weeks[1][2]).toEqual({ date: "2026-01-07", steps: 0, level: 0 })
|
||||
expect(calendar.weeks[1][3].level).toBe(-1)
|
||||
expect(calendar.months).toEqual([{ week: 0, label: "Jan", span: 2 }])
|
||||
})
|
||||
|
||||
test("clips to the latest weeks and keeps empty days distinct from out-of-range cells", () => {
|
||||
const calendar = activityCalendar({ ...input, activity: [], maxWeeks: 1 })
|
||||
expect(calendar.clipped).toBe(true)
|
||||
expect(calendar.weeks).toHaveLength(1)
|
||||
expect(calendar.weeks[0][0]).toEqual({ date: "2026-01-05", steps: 0, level: 0 })
|
||||
expect(calendar.weeks[0][3].level).toBe(-1)
|
||||
})
|
||||
|
||||
test("caps long histories at 53 weeks", () => {
|
||||
const calendar = activityCalendar({ ...input, from: new Date(2020, 0, 1).getTime(), maxWeeks: 200 })
|
||||
expect(calendar.clipped).toBe(true)
|
||||
expect(calendar.weeks).toHaveLength(53)
|
||||
expect(calendar.weeks.at(-1)?.[2].date).toBe("2026-01-07")
|
||||
expect(calendar.months.reduce((sum, month) => sum + month.span, 0)).toBe(53)
|
||||
})
|
||||
|
||||
test("returns month positions and spans for partial first and last months", () => {
|
||||
const calendar = activityCalendar({
|
||||
...input,
|
||||
from: new Date(2026, 3, 29).getTime(),
|
||||
to: new Date(2026, 4, 20).getTime(),
|
||||
})
|
||||
expect(calendar.months).toEqual([
|
||||
{ week: 0, label: "Apr", span: 1 },
|
||||
{ week: 1, label: "May", span: 3 },
|
||||
])
|
||||
const partial = activityCalendar({
|
||||
...input,
|
||||
from: new Date(2026, 3, 1).getTime(),
|
||||
to: new Date(2026, 3, 2).getTime(),
|
||||
})
|
||||
expect(partial.months).toEqual([{ week: 0, label: "Apr", span: 1 }])
|
||||
})
|
||||
|
||||
test("places month labels across a year boundary", () => {
|
||||
const calendar = activityCalendar({
|
||||
...input,
|
||||
from: new Date(2025, 11, 22).getTime(),
|
||||
to: new Date(2026, 0, 12).getTime(),
|
||||
})
|
||||
expect(calendar.months).toEqual([
|
||||
{ week: 0, label: "Dec", span: 1 },
|
||||
{ week: 1, label: "Jan", span: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps exactly seven distinct cells per week across DST and leap days", () => {
|
||||
const calendar = activityCalendar({
|
||||
from: new Date(2024, 1, 26).getTime(),
|
||||
to: new Date(2024, 2, 12).getTime(),
|
||||
maxWeeks: 53,
|
||||
activity: [
|
||||
{ date: "2024-02-29", steps: 1 },
|
||||
{ date: "2024-03-10", steps: 1 },
|
||||
],
|
||||
})
|
||||
expect(calendar.weeks).toHaveLength(3)
|
||||
expect(
|
||||
calendar.weeks
|
||||
.flat()
|
||||
.filter((day) => day.steps > 0)
|
||||
.map((day) => day.date),
|
||||
).toEqual(["2024-02-29", "2024-03-10"])
|
||||
expect(new Set(calendar.weeks.flat().map((day) => day.date)).size).toBe(21)
|
||||
})
|
||||
|
||||
test("uses the highest intensity for a single distinct positive count", () => {
|
||||
const calendar = activityCalendar({ ...input, activity: input.activity.map((day) => ({ ...day, steps: 10 })) })
|
||||
expect(
|
||||
calendar.weeks
|
||||
.flat()
|
||||
.filter((day) => day.steps > 0)
|
||||
.map((day) => day.level),
|
||||
).toEqual([4, 4])
|
||||
})
|
||||
|
||||
test("ranks distinct activity counts in four levels", () => {
|
||||
const calendar = activityCalendar({
|
||||
...input,
|
||||
to: new Date(2026, 0, 10).getTime(),
|
||||
activity: Array.from({ length: 8 }, (_, index) => ({ date: `2026-01-0${index + 1}`, steps: index + 1 })),
|
||||
})
|
||||
expect(
|
||||
calendar.weeks
|
||||
.flat()
|
||||
.filter((day) => day.steps > 0)
|
||||
.map((day) => day.level),
|
||||
).toEqual([1, 1, 2, 2, 3, 3, 4, 4])
|
||||
})
|
||||
|
||||
test("keeps intensity relative to the requested range when older weeks are clipped", () => {
|
||||
const calendar = activityCalendar({
|
||||
...input,
|
||||
maxWeeks: 1,
|
||||
activity: [
|
||||
{ date: "2026-01-01", steps: 50 },
|
||||
{ date: "2026-01-05", steps: 1 },
|
||||
],
|
||||
})
|
||||
expect(calendar.weeks[0][0].level).toBe(2)
|
||||
})
|
||||
|
||||
test("returns an empty calendar for an empty range", () => {
|
||||
expect(activityCalendar({ ...input, to: input.from, activity: [] })).toEqual({
|
||||
weeks: [],
|
||||
months: [],
|
||||
clipped: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
export function activityCalendar(input: {
|
||||
activity: readonly { date: string; steps: number }[]
|
||||
from: number
|
||||
to: number
|
||||
maxWeeks: number
|
||||
}) {
|
||||
// Extract local dates first, then use UTC ordinals so DST cannot add or drop cells.
|
||||
const ordinal = (time: number) => {
|
||||
const date = new Date(time)
|
||||
return Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000
|
||||
}
|
||||
const from = ordinal(input.from)
|
||||
const to = ordinal(input.to - 1)
|
||||
const monday = from - ((new Date(from * 86_400_000).getUTCDay() + 6) % 7)
|
||||
const total = input.to <= input.from ? 0 : Math.floor((to - monday) / 7) + 1
|
||||
const count = Math.min(total, 53, Math.max(1, Math.floor(input.maxWeeks)))
|
||||
const start = monday + Math.max(0, total - count) * 7
|
||||
const values = new Map(input.activity.map((day) => [day.date, day.steps]))
|
||||
const levels = [...new Set([...values.values()].filter((steps) => steps > 0))].sort((a, b) => a - b)
|
||||
const weeks = Array.from({ length: count }, (_, week) =>
|
||||
Array.from({ length: 7 }, (_, day) => {
|
||||
const date = start + week * 7 + day
|
||||
const key = new Date(date * 86_400_000).toISOString().slice(0, 10)
|
||||
const steps = values.get(key) ?? 0
|
||||
return {
|
||||
date: key,
|
||||
steps,
|
||||
level:
|
||||
date < from || date > to
|
||||
? -1
|
||||
: steps === 0
|
||||
? 0
|
||||
: Math.max(1, Math.ceil(((levels.indexOf(steps) + 1) / levels.length) * 4)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
const formatter = new Intl.DateTimeFormat("en-US", { month: "short", timeZone: "UTC" })
|
||||
// Thursday identifies the month owning most of a week; partial edge weeks stay inside the range.
|
||||
const months = weeks
|
||||
.map((_, week) => ({
|
||||
week,
|
||||
label: formatter.format(Math.min(to, Math.max(from, start + week * 7 + 3)) * 86_400_000),
|
||||
}))
|
||||
.filter((month, index, months) => index === 0 || month.label !== months[index - 1].label)
|
||||
return {
|
||||
weeks,
|
||||
months: months.map((month, index) => ({ ...month, span: (months[index + 1]?.week ?? count) - month.week })),
|
||||
clipped: count < total,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user