summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-11-20 09:54:20 -0500
committerFrank <[email protected]>2025-11-20 09:54:22 -0500
commit3632ba3785f58330b26f38b0281e2f0bd10d52c4 (patch)
tree84a0f0487c207ad71681f6ccd6ba7dd3363cd757
parentb7b3824d769611a658c727651a6132856c3be3be (diff)
downloadopencode-3632ba3785f58330b26f38b0281e2f0bd10d52c4.tar.gz
opencode-3632ba3785f58330b26f38b0281e2f0bd10d52c4.zip
zen: show token breakdown
-rw-r--r--bun.lock1
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.module.css67
-rw-r--r--packages/console/app/src/routes/workspace/[id]/usage-section.tsx63
3 files changed, 127 insertions, 4 deletions
diff --git a/bun.lock b/bun.lock
index 7d6907a72..62c29200d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
- "configVersion": 1,
"workspaces": {
"": {
"name": "opencode",
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
index f11e00b21..83c783a2f 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css
@@ -56,6 +56,53 @@
color: var(--color-text);
font-weight: 500;
}
+
+ [data-slot="tokens-with-breakdown"] {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ }
+
+ [data-slot="breakdown-button"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ background: transparent;
+ border: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ transition: color 0.15s ease;
+
+ &:hover {
+ color: var(--color-text);
+ }
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+ }
+
+ [data-slot="breakdown-popup"] {
+ position: absolute;
+ left: 0;
+ top: 100%;
+ margin-top: var(--space-2);
+ background: var(--color-bg);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ padding: var(--space-2);
+ z-index: 10;
+ min-width: 180px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ font-size: var(--font-size-xs);
+
+ @media (prefers-color-scheme: dark) {
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+ }
+ }
}
tbody tr:last-child td {
@@ -116,4 +163,24 @@
}
}
}
+
+ /* Breakdown popup content */
+ [data-slot="breakdown-row"] {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-4);
+ padding: var(--space-1) 0;
+ }
+
+ [data-slot="breakdown-label"] {
+ color: var(--color-text-muted);
+ font-size: var(--font-size-xs);
+ }
+
+ [data-slot="breakdown-value"] {
+ color: var(--color-text);
+ font-weight: 500;
+ font-size: var(--font-size-xs);
+ }
}
diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
index d9aa7251b..5c461b89d 100644
--- a/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
+++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx
@@ -1,6 +1,6 @@
import { Billing } from "@opencode-ai/console-core/billing.js"
import { createAsync, query, useParams } from "@solidjs/router"
-import { createMemo, For, Show, createEffect } from "solid-js"
+import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
import { formatDateUTC, formatDateForTable } from "../common"
import { withActor } from "~/context/auth.withActor"
import { IconChevronLeft, IconChevronRight } from "~/component/icon"
@@ -22,15 +22,34 @@ export function UsageSection() {
const params = useParams()
const usage = createAsync(() => queryUsageInfo(params.id!, 0))
const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
+ const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
createEffect(() => {
setStore({ usage: usage() })
}, [usage])
+ createEffect(() => {
+ if (!openBreakdownId()) return
+
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as HTMLElement
+ if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
+ setOpenBreakdownId(null)
+ }
+ }
+
+ document.addEventListener("click", handleClickOutside)
+ return () => document.removeEventListener("click", handleClickOutside)
+ })
+
const hasResults = createMemo(() => store.usage && store.usage.length > 0)
const canGoPrev = createMemo(() => store.page > 0)
const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
+ const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
+ return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
+ }
+
const goPrev = async () => {
const usage = await getUsageInfo(params.id!, store.page - 1)
setStore({
@@ -73,15 +92,53 @@ export function UsageSection() {
</thead>
<tbody>
<For each={store.usage}>
- {(usage) => {
+ {(usage, index) => {
const date = createMemo(() => new Date(usage.timeCreated))
+ const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
+ const breakdownId = `breakdown-${index()}`
+ const isOpen = createMemo(() => openBreakdownId() === breakdownId)
+ const isClaude = usage.model.toLowerCase().includes("claude")
return (
<tr>
<td data-slot="usage-date" title={formatDateUTC(date())}>
{formatDateForTable(date())}
</td>
<td data-slot="usage-model">{usage.model}</td>
- <td data-slot="usage-tokens">{usage.inputTokens}</td>
+ <td data-slot="usage-tokens">
+ <div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
+ <button
+ data-slot="breakdown-button"
+ onClick={(e) => {
+ e.stopPropagation()
+ setOpenBreakdownId(isOpen() ? null : breakdownId)
+ }}
+ >
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
+ <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" />
+ <path d="M8 4V8L11 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+ </svg>
+ </button>
+ <span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
+ <Show when={isOpen()}>
+ <div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">Input</span>
+ <span data-slot="breakdown-value">{usage.inputTokens}</span>
+ </div>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">Cache Read</span>
+ <span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
+ </div>
+ <Show when={isClaude}>
+ <div data-slot="breakdown-row">
+ <span data-slot="breakdown-label">Cache Write</span>
+ <span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
+ </div>
+ </Show>
+ </div>
+ </Show>
+ </div>
+ </td>
<td data-slot="usage-tokens">{usage.outputTokens}</td>
<td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
</tr>