Просмотр исходного кода

feat(webgui): add token usage stats display to assistant messages

Add MessageStats component that shows token usage breakdown (input, output, reasoning, cache read/write) and cost in a popover. Display stats button on hover for assistant messages with token data. Make fork/revert buttons conditional on user messages only. Add auto-positioning logic to show popover above or below based on available space.
paviko 3 месяцев назад
Родитель
Сommit
2e412ff015

+ 2 - 0
hosts/jetbrains-plugin/changelog.html

@@ -3,6 +3,8 @@
 <h3>26.1.2</h3>
 <h3>26.1.2</h3>
 <ul>
 <ul>
   <li>OAuth Instructions: Support for provider-specific instructions during OAuth flow</li>
   <li>OAuth Instructions: Support for provider-specific instructions during OAuth flow</li>
+  <li>Token Usage Stats: Display token usage breakdown and cost in a popover for assistant messages</li>
+  <li>Updated OpenCode to v1.0.223</li>
 </ul>
 </ul>
 
 
 <h3>25.12.29</h3>
 <h3>25.12.29</h3>

+ 2 - 0
hosts/vscode-plugin/CHANGELOG.md

@@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### [26.1.2] - 2026-01-02
 ### [26.1.2] - 2026-01-02
 
 
 - OAuth Instructions: Support for provider-specific instructions during OAuth flow
 - OAuth Instructions: Support for provider-specific instructions during OAuth flow
+- Token Usage Stats: Display token usage breakdown and cost in a popover for assistant messages
+- Updated OpenCode to v1.0.223
 
 
 ## [25.11.30] - 2025-11-30
 ## [25.11.30] - 2025-11-30
 
 

+ 60 - 38
packages/opencode/webgui/src/components/MessageList/ActionButtons.tsx

@@ -1,48 +1,70 @@
 import { IconButton } from "../common"
 import { IconButton } from "../common"
+import { MessageStats } from "./MessageStats"
+
+interface TokenData {
+  input: number
+  output: number
+  reasoning: number
+  cache: {
+    read: number
+    write: number
+  }
+}
 
 
 interface ActionButtonsProps {
 interface ActionButtonsProps {
-  onFork: () => void
-  onRevert: () => void
-  revertBusy: boolean
+  onFork?: () => void
+  onRevert?: () => void
+  revertBusy?: boolean
+  tokens?: TokenData
+  cost?: number
+  isUser?: boolean
 }
 }
 
 
-export function ActionButtons({ onFork, onRevert, revertBusy }: ActionButtonsProps) {
+export function ActionButtons({ onFork, onRevert, revertBusy, tokens, cost, isUser }: ActionButtonsProps) {
+  const hasTokens =
+    tokens && (tokens.input > 0 || tokens.output > 0 || tokens.reasoning > 0 || tokens.cache.read > 0 || tokens.cache.write > 0)
+
   return (
   return (
     <div className="absolute left-1/2 -translate-x-1/2 top-0 flex gap-2">
     <div className="absolute left-1/2 -translate-x-1/2 top-0 flex gap-2">
-      <IconButton
-        onClick={onFork}
-        size="md"
-        aria-label="Fork session at this message"
-        title="Fork session at this message"
-        icon={
-          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-            <path
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              strokeWidth={2}
-              d="M7 4v4a4 4 0 004 4h2a4 4 0 014 4v4M7 4h4M7 4H3M17 20h4M17 20l-3-3"
-            />
-          </svg>
-        }
-      />
-      <IconButton
-        onClick={onRevert}
-        size="md"
-        disabled={revertBusy}
-        aria-label="Undo from this message (revert)"
-        title="Undo from this message (revert)"
-        className="hover:text-red-600 dark:hover:text-red-400"
-        icon={
-          <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-            <path
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              strokeWidth={2}
-              d="M9 5H5v4m0-4l4 4m2-4h3a5 5 0 010 10H9"
-            />
-          </svg>
-        }
-      />
+      {isUser && onFork && (
+        <IconButton
+          onClick={onFork}
+          size="md"
+          aria-label="Fork session at this message"
+          title="Fork session at this message"
+          icon={
+            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M7 4v4a4 4 0 004 4h2a4 4 0 014 4v4M7 4h4M7 4H3M17 20h4M17 20l-3-3"
+              />
+            </svg>
+          }
+        />
+      )}
+      {isUser && onRevert && (
+        <IconButton
+          onClick={onRevert}
+          size="md"
+          disabled={revertBusy}
+          aria-label="Undo from this message (revert)"
+          title="Undo from this message (revert)"
+          className="hover:text-red-600 dark:hover:text-red-400"
+          icon={
+            <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M9 5H5v4m0-4l4 4m2-4h3a5 5 0 010 10H9"
+              />
+            </svg>
+          }
+        />
+      )}
+      {hasTokens && tokens && typeof cost === "number" && <MessageStats tokens={tokens} cost={cost} />}
     </div>
     </div>
   )
   )
 }
 }

+ 14 - 2
packages/opencode/webgui/src/components/MessageList/MessageRow.tsx

@@ -1,5 +1,6 @@
 import { useState } from "react"
 import { useState } from "react"
 import type { Message } from "../../state/MessagesContext"
 import type { Message } from "../../state/MessagesContext"
+import { isAssistantMessage, type AssistantMessage } from "../../types/messages"
 import { MessagePart } from "./MessagePart"
 import { MessagePart } from "./MessagePart"
 import { ActionButtons } from "./ActionButtons"
 import { ActionButtons } from "./ActionButtons"
 import { getPartStart, getPartEnd } from "./utils"
 import { getPartStart, getPartEnd } from "./utils"
@@ -16,8 +17,16 @@ interface MessageRowProps {
 export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID }: MessageRowProps) {
 export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID }: MessageRowProps) {
   const [isHovered, setIsHovered] = useState(false)
   const [isHovered, setIsHovered] = useState(false)
   const isUser = message.info.role === "user"
   const isUser = message.info.role === "user"
+  const isAssistant = isAssistantMessage(message.info)
   const skipPartIds = new Set<string>()
   const skipPartIds = new Set<string>()
 
 
+  const assistantInfo = isAssistant ? (message.info as AssistantMessage) : null
+  const tokens = assistantInfo?.tokens
+  const cost = assistantInfo?.cost
+
+  const hasTokens =
+    tokens && (tokens.input > 0 || tokens.output > 0 || tokens.reasoning > 0 || tokens.cache.read > 0 || tokens.cache.write > 0)
+
   // Calculate durations for reasoning parts using timestamps when available
   // Calculate durations for reasoning parts using timestamps when available
   const partsWithDurations = message.parts.map((part) => {
   const partsWithDurations = message.parts.map((part) => {
     let durationMs: number | undefined
     let durationMs: number | undefined
@@ -43,12 +52,15 @@ export function MessageRow({ message, onFork, onRevert, revertBusy, sessionID }:
       onMouseEnter={() => setIsHovered(true)}
       onMouseEnter={() => setIsHovered(true)}
       onMouseLeave={() => setIsHovered(false)}
       onMouseLeave={() => setIsHovered(false)}
     >
     >
-      {/* Fork / Undo buttons (visible on hover) */}
-      {isUser && isHovered && (
+      {/* Action buttons (visible on hover) */}
+      {isHovered && (isUser || hasTokens) && (
         <ActionButtons
         <ActionButtons
           onFork={() => onFork(message.info.id)}
           onFork={() => onFork(message.info.id)}
           onRevert={() => onRevert(message.info.id)}
           onRevert={() => onRevert(message.info.id)}
           revertBusy={revertBusy}
           revertBusy={revertBusy}
+          tokens={tokens}
+          cost={cost}
+          isUser={isUser}
         />
         />
       )}
       )}
 
 

+ 121 - 0
packages/opencode/webgui/src/components/MessageList/MessageStats.tsx

@@ -0,0 +1,121 @@
+import { useState, useRef, useEffect, useLayoutEffect } from "react"
+import { formatK, formatCost } from "../../utils/formatting"
+import { cn } from "../../utils/classNames"
+
+interface TokenData {
+  input: number
+  output: number
+  reasoning: number
+  cache: {
+    read: number
+    write: number
+  }
+}
+
+interface MessageStatsProps {
+  tokens: TokenData
+  cost: number
+}
+
+export function MessageStats({ tokens, cost }: MessageStatsProps) {
+  const [showDetails, setShowDetails] = useState(false)
+  const [position, setPosition] = useState<"above" | "below">("below")
+  const popoverRef = useRef<HTMLDivElement>(null)
+  const buttonRef = useRef<HTMLButtonElement>(null)
+
+  useLayoutEffect(() => {
+    if (!showDetails || !buttonRef.current) return
+
+    const buttonRect = buttonRef.current.getBoundingClientRect()
+    const spaceAbove = buttonRect.top
+    const popoverHeight = 220
+
+    if (spaceAbove < popoverHeight) {
+      setPosition("below")
+    } else {
+      setPosition("above")
+    }
+  }, [showDetails])
+
+  useEffect(() => {
+    if (!showDetails) return
+
+    function handleClickOutside(event: MouseEvent) {
+      if (
+        popoverRef.current &&
+        !popoverRef.current.contains(event.target as Node) &&
+        buttonRef.current &&
+        !buttonRef.current.contains(event.target as Node)
+      ) {
+        setShowDetails(false)
+      }
+    }
+
+    document.addEventListener("mousedown", handleClickOutside)
+    return () => document.removeEventListener("mousedown", handleClickOutside)
+  }, [showDetails])
+
+  const total = tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+
+  return (
+    <div className="relative">
+      <button
+        ref={buttonRef}
+        onClick={() => setShowDetails((v) => !v)}
+        className="flex items-center justify-center w-7 h-7 rounded-md text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-gray-100 transition-colors"
+        aria-label="Show token usage"
+        title="Show token usage"
+      >
+        <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
+          <path
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            strokeWidth={2}
+            d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
+          />
+        </svg>
+      </button>
+
+      {showDetails && (
+        <div
+          ref={popoverRef}
+          className={cn(
+            "modern-card absolute left-1/2 -translate-x-1/2 w-48 z-50 overflow-hidden ring-1 ring-black/5 p-2 text-xs",
+            position === "above" ? "bottom-full mb-2" : "top-full mt-2"
+          )}
+        >
+          <div className="space-y-1">
+            <div className="flex items-center justify-between py-0.5 font-medium border-b border-gray-200 dark:border-gray-700 pb-1 mb-1">
+              <span>Total</span>
+              <span className="tabular-nums">{formatK(total)}</span>
+            </div>
+            <div className="flex items-center justify-between py-0.5">
+              <span className="text-gray-600 dark:text-gray-400">Input</span>
+              <span className="tabular-nums">{formatK(tokens.input)}</span>
+            </div>
+            <div className="flex items-center justify-between py-0.5">
+              <span className="text-gray-600 dark:text-gray-400">Cache read</span>
+              <span className="tabular-nums">{formatK(tokens.cache.read)}</span>
+            </div>
+            <div className="flex items-center justify-between py-0.5">
+              <span className="text-gray-600 dark:text-gray-400">Cache write</span>
+              <span className="tabular-nums">{formatK(tokens.cache.write)}</span>
+            </div>
+            <div className="flex items-center justify-between py-0.5">
+              <span className="text-gray-600 dark:text-gray-400">Output</span>
+              <span className="tabular-nums">{formatK(tokens.output)}</span>
+            </div>
+            <div className="flex items-center justify-between py-0.5">
+              <span className="text-gray-600 dark:text-gray-400">Reasoning</span>
+              <span className="tabular-nums">{formatK(tokens.reasoning)}</span>
+            </div>
+            <div className="flex items-center justify-between py-0.5 border-t border-gray-200 dark:border-gray-700 pt-1 mt-1">
+              <span className="text-gray-600 dark:text-gray-400">Cost</span>
+              <span className="tabular-nums">{formatCost(cost)}</span>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}