diff --git a/packages/cli/src/commands/handlers/stats.ts b/packages/cli/src/commands/handlers/stats.ts
index 3502ac5efe2..321007a73df 100644
--- a/packages/cli/src/commands/handlers/stats.ts
+++ b/packages/cli/src/commands/handlers/stats.ts
@@ -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)
-}
diff --git a/packages/cli/test/stats.test.ts b/packages/cli/test/stats.test.ts
index 0fdea8ad08c..e3d3d81b09a 100644
--- a/packages/cli/test/stats.test.ts
+++ b/packages/cli/test/stats.test.ts
@@ -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")
diff --git a/packages/core/src/session/stats.ts b/packages/core/src/session/stats.ts
index 057ca61fe44..0704d97bf6d 100644
--- a/packages/core/src/session/stats.ts
+++ b/packages/core/src/session/stats.ts
@@ -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,
diff --git a/packages/schema/src/token-usage.ts b/packages/schema/src/token-usage.ts
index 6f08add0cc6..b8ce1d2ec9c 100644
--- a/packages/schema/src/token-usage.ts
+++ b/packages/schema/src/token-usage.ts
@@ -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
+}
diff --git a/packages/schema/test/token-usage.test.ts b/packages/schema/test/token-usage.test.ts
new file mode 100644
index 00000000000..ee5e850cb71
--- /dev/null
+++ b/packages/schema/test/token-usage.test.ts
@@ -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)
+})
diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index b81c29b9b66..752830b5a77 100644
--- a/packages/tui/src/app.tsx
+++ b/packages/tui/src/app.tsx
@@ -1321,7 +1321,7 @@ function App(props: { pair?: DialogPairCredentials }) {
-
+
diff --git a/packages/tui/src/feature-plugins/system/stats-data.ts b/packages/tui/src/feature-plugins/system/stats-data.ts
new file mode 100644
index 00000000000..cafd2835320
--- /dev/null
+++ b/packages/tui/src/feature-plugins/system/stats-data.ts
@@ -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)
+}
diff --git a/packages/tui/src/feature-plugins/system/stats.tsx b/packages/tui/src/feature-plugins/system/stats.tsx
new file mode 100644
index 00000000000..7cce828cf91
--- /dev/null
+++ b/packages/tui/src/feature-plugins/system/stats.tsx
@@ -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 = {
+ "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 (
+
+
+
+ opencode / stats
+
+ {dates()}
+
+
+
+
+
+
+ {number()}
+
+ }
+ >
+
+
+ {(row) => (
+
+ {letters()
+ .map((char) => char[row].replaceAll("1", "\u2588\u2588").replaceAll("0", " "))
+ .join(" ")}
+
+ )}
+
+
+
+ TOKENS
+
+
+
+ {" " +
+ calendar()
+ .months.map((month) => (month.label.length <= month.span * 2 ? month.label : "").padEnd(month.span * 2))
+ .join("")}
+
+
+ {(day, index) => (
+
+ {day + " "}
+
+ {(week) => (
+
+ {week[index()].level < 0 ? " " : week[index()].level === 0 ? "\u00b7 " : "\u25a0 "}
+
+ )}
+
+
+ )}
+
+
+ Your last {calendar().weeks.length} weeks
+
+
+
+
+ {(metric) => (
+
+
+ {statsNumber(metric.value)}
+ {metric.label === "best streak" ? " days" : ""}
+
+ {metric.label}
+
+ )}
+
+
+
+ opencode.ai
+
+
+ )
+}
+
+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 (
+
+
+ Could not load stats. Reopen /stats to try again.
+ }
+ >
+ Gathering your stats...}>
+ {(value) => }
+
+
+
+
+ )
+}
+
+export default Plugin.define({
+ id: "opencode.stats",
+ setup(context) {
+ const [previous, setPrevious] = createSignal({ ...context.ui.router.current() })
+ context.ui.router.register({
+ name: "stats",
+ render: () => 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
+ },
+ })
+ },
+})
diff --git a/packages/tui/src/plugin/builtins.ts b/packages/tui/src/plugin/builtins.ts
index fabfaff68f3..a2cacb8513b 100644
--- a/packages/tui/src/plugin/builtins.ts
+++ b/packages/tui/src/plugin/builtins.ts
@@ -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
diff --git a/packages/tui/test/stats.test.ts b/packages/tui/test/stats.test.ts
new file mode 100644
index 00000000000..a694fec854f
--- /dev/null
+++ b/packages/tui/test/stats.test.ts
@@ -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"])
+ })
+})
diff --git a/packages/tui/test/stats.view.test.tsx b/packages/tui/test/stats.view.test.tsx
new file mode 100644
index 00000000000..c5f1f222b9d
--- /dev/null
+++ b/packages/tui/test/stats.view.test.tsx
@@ -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))
+ }
+})
diff --git a/packages/util/src/activity-calendar.test.ts b/packages/util/src/activity-calendar.test.ts
new file mode 100644
index 00000000000..07458905f3b
--- /dev/null
+++ b/packages/util/src/activity-calendar.test.ts
@@ -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,
+ })
+ })
+})
diff --git a/packages/util/src/activity-calendar.ts b/packages/util/src/activity-calendar.ts
new file mode 100644
index 00000000000..eb89d1cb23b
--- /dev/null
+++ b/packages/util/src/activity-calendar.ts
@@ -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,
+ }
+}