Frank 3 месяцев назад
Родитель
Сommit
3632ba3785

+ 0 - 1
bun.lock

@@ -1,6 +1,5 @@
 {
   "lockfileVersion": 1,
-  "configVersion": 1,
   "workspaces": {
     "": {
       "name": "opencode",

+ 67 - 0
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);
+  }
 }

+ 60 - 3
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>