diff options
| author | Frank <[email protected]> | 2025-11-20 09:54:20 -0500 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-11-20 09:54:22 -0500 |
| commit | 3632ba3785f58330b26f38b0281e2f0bd10d52c4 (patch) | |
| tree | 84a0f0487c207ad71681f6ccd6ba7dd3363cd757 | |
| parent | b7b3824d769611a658c727651a6132856c3be3be (diff) | |
| download | opencode-3632ba3785f58330b26f38b0281e2f0bd10d52c4.tar.gz opencode-3632ba3785f58330b26f38b0281e2f0bd10d52c4.zip | |
zen: show token breakdown
| -rw-r--r-- | bun.lock | 1 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/usage-section.module.css | 67 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id]/usage-section.tsx | 63 |
3 files changed, 127 insertions, 4 deletions
@@ -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> |
