Skip to content
Open
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
18 changes: 18 additions & 0 deletions app/api/usage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { aggregateUsage, scanUsage, type UsageRange } from "@/lib/usage-store";

const RANGES: UsageRange[] = ["today", "7d", "30d", "all"];

export async function GET(req: Request) {
try {
const param = new URL(req.url).searchParams.get("range");
const range: UsageRange = RANGES.includes(param as UsageRange) ? (param as UsageRange) : "7d";
await scanUsage();
return NextResponse.json(aggregateUsage(range));
} catch (error) {
return NextResponse.json(
{ error: String(error) },
{ status: 500 }
);
}
}
35 changes: 35 additions & 0 deletions components/SessionSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SessionInfo } from "@/lib/types";
import { useI18n } from "@/hooks/useI18n";
import { DirectoryPicker } from "./DirectoryPicker";
import { FileExplorer, type FileExplorerHandle } from "./FileExplorer";
import { UsageStats } from "./UsageStats";

declare global {
interface Window {
Expand Down Expand Up @@ -394,6 +395,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
const [projectFilter, setProjectFilter] = useState("");
const [wtFilter, setWtFilter] = useState("");
const [customPathOpen, setCustomPathOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [customPathValue, setCustomPathValue] = useState("");
const [customPathError, setCustomPathError] = useState<string | null>(null);
const [customPathValidating, setCustomPathValidating] = useState(false);
Expand Down Expand Up @@ -872,6 +874,7 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
onSelect={(path) => void commitCustomPath(path)}
/>
)}
{usageOpen && <UsageStats onClose={() => setUsageOpen(false)} />}
{/* Header */}
<div
style={{
Expand All @@ -883,6 +886,38 @@ export function SessionSidebar({ selectedSessionId, onSelectSession, onNewSessio
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
<PiWebTitle />
<div style={{ display: "flex", gap: 6 }}>
<button
onClick={() => setUsageOpen(true)}
title={t("usage.buttonTitle")}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
background: "var(--bg-hover)",
border: "1px solid var(--border)",
color: "var(--text-muted)",
cursor: "pointer",
width: 32, height: 32,
borderRadius: 7,
padding: 0,
flexShrink: 0,
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--bg-selected)";
e.currentTarget.style.color = "var(--accent)";
e.currentTarget.style.borderColor = "rgba(37,99,235,0.35)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "var(--bg-hover)";
e.currentTarget.style.color = "var(--text-muted)";
e.currentTarget.style.borderColor = "var(--border)";
}}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="20" x2="5" y2="14" />
<line x1="12" y1="20" x2="12" y2="9" />
<line x1="19" y1="20" x2="19" y2="4" />
</svg>
</button>
<button
onClick={handleNewSession}
disabled={!selectedCwd}
Expand Down
211 changes: 211 additions & 0 deletions components/UsageStats.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { useI18n } from "@/hooks/useI18n";
import { useIsMobile } from "@/hooks/useIsMobile";
import type { UsageRange, UsageReport } from "@/lib/usage-store";

const RANGES: UsageRange[] = ["today", "7d", "30d", "all"];

function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k`;
return String(tokens);
}

function formatCost(cost: number): string {
if (cost >= 100) return `$${cost.toFixed(0)}`;
if (cost >= 1) return `$${cost.toFixed(2)}`;
return `$${cost.toFixed(4)}`;
}

export function UsageStats({ onClose }: { onClose: () => void }) {
const { t } = useI18n();
const isMobile = useIsMobile();
const [range, setRange] = useState<UsageRange>("7d");
const [report, setReport] = useState<UsageReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

const load = useCallback(async (r: UsageRange) => {
setLoading(true);
try {
const res = await fetch(`/api/usage?range=${r}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
setReport((await res.json()) as UsageReport);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
void load(range);
}, [range, load]);

useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);

const totals = report?.totals;
const totalTokens = totals ? totals.input + totals.output + totals.cacheRead + totals.cacheWrite : 0;
const maxDailyCost = report ? Math.max(...report.daily.map((d) => d.cost), 0) : 0;
const installedDate = report ? new Date(report.installedAt).toLocaleDateString() : "";

const card = (label: string, value: string) => (
<div key={label} style={{
flex: 1, minWidth: 110, padding: "10px 12px",
background: "var(--bg-panel)", border: "1px solid var(--border)", borderRadius: 8,
}}>
<div style={{ fontSize: 11, color: "var(--text-dim)", marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: 17, fontWeight: 700, color: "var(--text)", fontFamily: "var(--font-mono)" }}>{value}</div>
</div>
);

return (
<div
style={{ position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,0.35)", display: "flex", alignItems: "center", justifyContent: "center" }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div style={{
width: isMobile ? "calc(100vw - 16px)" : 720, maxWidth: "calc(100vw - 16px)",
maxHeight: "78vh", background: "var(--bg)", border: "1px solid var(--border)",
borderRadius: 10, display: "flex", flexDirection: "column",
boxShadow: "0 8px 32px rgba(0,0,0,0.18)", overflow: "hidden",
}}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("usage.title")}</span>
{installedDate && (
<span style={{ fontSize: 11, color: "var(--text-dim)" }}>{t("usage.since", { date: installedDate })}</span>
)}
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>×</button>
</div>

{/* Range tabs */}
<div style={{ display: "flex", gap: 6, padding: "10px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
{RANGES.map((r) => {
const active = r === range;
return (
<button
key={r}
onClick={() => setRange(r)}
style={{
padding: "4px 12px", fontSize: 12, borderRadius: 6, cursor: "pointer",
border: `1px solid ${active ? "rgba(37,99,235,0.35)" : "var(--border)"}`,
background: active ? "var(--bg-selected)" : "none",
color: active ? "var(--accent)" : "var(--text-muted)",
fontWeight: active ? 600 : 400,
transition: "background 0.12s, color 0.12s, border-color 0.12s",
}}
>
{t(`usage.range.${r}`)}
</button>
);
})}
</div>

{/* Body */}
<div style={{ flex: 1, overflowY: "auto", padding: 18 }}>
{loading && !report ? (
<div style={{ padding: "30px 0", fontSize: 12, color: "var(--text-dim)", textAlign: "center" }}>{t("usage.loading")}</div>
) : error ? (
<div style={{ padding: "30px 0", fontSize: 12, color: "var(--text-dim)", textAlign: "center" }}>{t("usage.error")}</div>
) : !report || report.models.length === 0 ? (
<div style={{ padding: "30px 0", fontSize: 12, color: "var(--text-dim)", textAlign: "center" }}>
{t("usage.empty", { date: installedDate })}
</div>
) : (
<>
{/* Summary cards */}
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginBottom: 16 }}>
{card(t("usage.totalCost"), formatCost(totals!.cost))}
{card(t("usage.totalTokens"), formatTokens(totalTokens))}
{card(t("usage.messages"), String(totals!.messages))}
{card(t("usage.sessions"), String(totals!.sessions))}
</div>

{/* Daily cost bars */}
{report.daily.length > 1 && (
<div style={{ marginBottom: 16 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.07em", marginBottom: 8 }}>
{t("usage.daily")}
</div>
<div style={{ display: "flex", alignItems: "flex-end", gap: 3, height: 56, padding: "6px 8px", background: "var(--bg-panel)", border: "1px solid var(--border)", borderRadius: 8 }}>
{report.daily.map((d) => (
<div
key={d.day}
title={`${d.day} · ${formatCost(d.cost)} · ${formatTokens(d.tokens)} tok`}
style={{
flex: 1, minWidth: 3, borderRadius: 2,
height: `${maxDailyCost > 0 ? Math.max((d.cost / maxDailyCost) * 100, 4) : 4}%`,
background: d.cost > 0 ? "var(--accent)" : "var(--border)",
opacity: d.cost > 0 ? 0.85 : 0.5,
}}
/>
))}
</div>
</div>
)}

{/* Per-model table */}
<div style={{ border: "1px solid var(--border)", borderRadius: 8, overflow: "hidden" }}>
<div style={{
display: "grid", gridTemplateColumns: "minmax(140px, 1.6fr) repeat(4, minmax(56px, 0.8fr)) minmax(72px, 0.9fr) minmax(90px, 1fr)",
padding: "8px 12px", gap: 8, background: "var(--bg-panel)",
fontSize: 11, fontWeight: 600, color: "var(--text-dim)", textTransform: "uppercase", letterSpacing: "0.05em",
}}>
<span>{t("usage.model")}</span>
<span style={{ textAlign: "right" }}>{t("session.input")}</span>
<span style={{ textAlign: "right" }}>{t("session.output")}</span>
<span style={{ textAlign: "right" }}>{t("session.cacheRead")}</span>
<span style={{ textAlign: "right" }}>{t("session.cacheWrite")}</span>
<span style={{ textAlign: "right" }}>{t("session.cost")}</span>
<span>{t("usage.share")}</span>
</div>
{report.models.map((m) => {
const share = totals!.cost > 0 ? m.cost / totals!.cost : 0;
return (
<div
key={`${m.provider}/${m.model}`}
style={{
display: "grid", gridTemplateColumns: "minmax(140px, 1.6fr) repeat(4, minmax(56px, 0.8fr)) minmax(72px, 0.9fr) minmax(90px, 1fr)",
padding: "8px 12px", gap: 8, alignItems: "center",
borderTop: "1px solid var(--border)", fontSize: 12, color: "var(--text)",
}}
>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={`${m.provider}/${m.model}`}>
<span style={{ color: "var(--text-dim)" }}>{m.provider}/</span>{m.model}
</span>
<span style={{ textAlign: "right", fontFamily: "var(--font-mono)", color: "var(--text-muted)" }}>{formatTokens(m.input)}</span>
<span style={{ textAlign: "right", fontFamily: "var(--font-mono)", color: "var(--text-muted)" }}>{formatTokens(m.output)}</span>
<span style={{ textAlign: "right", fontFamily: "var(--font-mono)", color: "var(--text-muted)" }}>{formatTokens(m.cacheRead)}</span>
<span style={{ textAlign: "right", fontFamily: "var(--font-mono)", color: "var(--text-muted)" }}>{formatTokens(m.cacheWrite)}</span>
<span style={{ textAlign: "right", fontFamily: "var(--font-mono)", fontWeight: 600 }}>{formatCost(m.cost)}</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ flex: 1, height: 4, borderRadius: 2, background: "var(--border)", overflow: "hidden" }}>
<span style={{ display: "block", height: "100%", width: `${Math.max(share * 100, 2)}%`, background: "var(--accent)", borderRadius: 2 }} />
</span>
<span style={{ fontSize: 11, color: "var(--text-dim)", fontFamily: "var(--font-mono)", width: 38, textAlign: "right" }}>
{(share * 100).toFixed(0)}%
</span>
</span>
</div>
);
})}
</div>

<div style={{ marginTop: 10, fontSize: 11, color: "var(--text-dim)" }}>{t("usage.disclaimer")}</div>
</>
)}
</div>
</div>
</div>
);
}
18 changes: 18 additions & 0 deletions lib/i18n/messages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ export const enLocale: LocalePlugin = {
"session.tokens": "Tokens",
"session.copyFile": "Copy file path",
"session.copyId": "Copy session ID",
"usage.title": "Usage",
"usage.buttonTitle": "Usage & cost stats",
"usage.since": "tracking since {date}",
"usage.range.today": "Today",
"usage.range.7d": "7 days",
"usage.range.30d": "30 days",
"usage.range.all": "All",
"usage.totalCost": "Total cost",
"usage.totalTokens": "Total tokens",
"usage.messages": "Messages",
"usage.sessions": "Sessions",
"usage.daily": "Daily cost",
"usage.model": "Model",
"usage.share": "Share",
"usage.loading": "Loading...",
"usage.error": "Failed to load usage stats",
"usage.empty": "No usage recorded yet — tracking started on {date}",
"usage.disclaimer": "Costs are estimates recorded by pi for each message. Only usage after tracking started is counted.",
"workspace.opening": "Opening workspace...",
"workspace.unable": "Unable to open workspace",
"workspace.selectSession": "Select a session from the sidebar",
Expand Down
18 changes: 18 additions & 0 deletions lib/i18n/messages/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,24 @@ export const zhCNLocale: LocalePlugin = {
"session.tokens": "Token",
"session.copyFile": "复制文件路径",
"session.copyId": "复制会话 ID",
"usage.title": "用量",
"usage.buttonTitle": "用量与费用统计",
"usage.since": "自 {date} 起记录",
"usage.range.today": "今天",
"usage.range.7d": "近 7 天",
"usage.range.30d": "近 30 天",
"usage.range.all": "全部",
"usage.totalCost": "总费用",
"usage.totalTokens": "总 Token",
"usage.messages": "消息",
"usage.sessions": "会话",
"usage.daily": "每日费用",
"usage.model": "模型",
"usage.share": "占比",
"usage.loading": "加载中...",
"usage.error": "用量统计加载失败",
"usage.empty": "还没有记录 —— 自 {date} 起产生的用量才会被统计",
"usage.disclaimer": "费用为 pi 按每条消息记录的估算值,仅统计开始记录之后产生的用量。",
"workspace.opening": "正在打开工作区...",
"workspace.unable": "无法打开工作区",
"workspace.selectSession": "从侧边栏选择一个会话",
Expand Down
Loading