feat(desktop): Display stored totals for Tokens and Cost in Desktop Session Context (#28887)

This commit is contained in:
OpeOginni 2026-06-29 01:17:08 +02:00 committed by GitHub
commit 683aca5dbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 74 additions and 78 deletions

View file

@ -9,7 +9,7 @@ import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { createSessionTabs } from "@/pages/session/helpers" import { createSessionTabs } from "@/pages/session/helpers"
@ -45,6 +45,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}) })
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
const usd = createMemo( const usd = createMemo(
() => () =>
@ -54,10 +55,10 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const context = createMemo(() => metrics().context) const tokens = createMemo(() => info()?.tokens)
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(info()?.cost ?? 0)
}) })
const openContext = () => { const openContext = () => {
@ -82,18 +83,20 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const tooltipValue = () => ( const tooltipValue = () => (
<div> <div>
<Show when={tokens()}>
{(value) => (
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{getSessionTokenTotal(value())?.toLocaleString(language.intl())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
</div>
)}
</Show>
<Show when={context()}> <Show when={context()}>
{(ctx) => ( {(ctx) => (
<> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span> <span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span> </div>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().usage ?? 0}%</span>
<span class="text-text-invert-base">{language.t("context.usage.usage")}</span>
</div>
</>
)} )}
</Show> </Show>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client" import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
const assistant = ( const assistant = (
id: string, id: string,
@ -37,8 +37,8 @@ const user = (id: string) => {
} as unknown as Message } as unknown as Message
} }
describe("getSessionContextMetrics", () => { describe("getSessionContext", () => {
test("computes totals and usage from latest assistant with tokens", () => { test("computes usage from latest assistant with tokens", () => {
const messages = [ const messages = [
user("u1"), user("u1"),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
@ -57,45 +57,52 @@ describe("getSessionContextMetrics", () => {
}, },
] ]
const metrics = getSessionContextMetrics(messages, providers) const ctx = getSessionContext(messages, providers)
expect(metrics.totalCost).toBe(1.75) expect(ctx?.message.id).toBe("a2")
expect(metrics.context?.message.id).toBe("a2") expect(ctx?.usage).toBe(50)
expect(metrics.context?.total).toBe(500) expect(ctx?.providerLabel).toBe("OpenAI")
expect(metrics.context?.usage).toBe(50) expect(ctx?.modelLabel).toBe("GPT-4.1")
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
}) })
test("preserves fallback labels and null usage when model metadata is missing", () => { test("preserves fallback labels and null usage when model metadata is missing", () => {
const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")] const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
const providers = [{ id: "p-1", models: {} }] const providers = [{ id: "p-1", models: {} }]
const metrics = getSessionContextMetrics(messages, providers) const ctx = getSessionContext(messages, providers)
expect(metrics.context?.providerLabel).toBe("p-1") expect(ctx?.providerLabel).toBe("p-1")
expect(metrics.context?.modelLabel).toBe("m-1") expect(ctx?.modelLabel).toBe("m-1")
expect(metrics.context?.limit).toBeUndefined() expect(ctx?.limit).toBeUndefined()
expect(metrics.context?.usage).toBeNull() expect(ctx?.usage).toBeNull()
}) })
test("recomputes when message array is mutated in place", () => { test("recomputes when message array is mutated in place", () => {
const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)]
const providers = [{ id: "openai", models: {} }] const providers = [{ id: "openai", models: {} }]
const one = getSessionContextMetrics(messages, providers) const one = getSessionContext(messages, providers)
messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75))
const two = getSessionContextMetrics(messages, providers) const two = getSessionContext(messages, providers)
expect(one.context?.message.id).toBe("a1") expect(one?.message.id).toBe("a1")
expect(two.context?.message.id).toBe("a2") expect(two?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
}) })
test("returns empty metrics when inputs are undefined", () => { test("returns undefined when inputs are undefined", () => {
const metrics = getSessionContextMetrics(undefined, undefined) const ctx = getSessionContext(undefined, undefined)
expect(metrics.totalCost).toBe(0) expect(ctx).toBeUndefined()
expect(metrics.context).toBeUndefined() })
test("computes stored session token totals", () => {
expect(
getSessionTokenTotal({
input: 10,
output: 20,
reasoning: 30,
cache: { read: 40, write: 50 },
}),
).toBe(150)
}) })
}) })

View file

@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
type Provider = { type Provider = {
id: string id: string
@ -21,19 +21,9 @@ type Context = {
modelLabel: string modelLabel: string
limit: number | undefined limit: number | undefined
input: number input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usage: number | null usage: number | null
} }
type Metrics = {
totalCost: number
context: Context | undefined
}
const tokenTotal = (msg: AssistantMessage) => { const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
} }
@ -47,10 +37,9 @@ const lastAssistantWithTokens = (messages: Message[]) => {
} }
} }
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { const build = (messages: Message[] = [], providers: Provider[] = []): Context | undefined => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages) const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined } if (!message) return undefined
const provider = providers.find((item) => item.id === message.providerID) const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID] const model = provider?.models[message.modelID]
@ -58,25 +47,22 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
const total = tokenTotal(message) const total = tokenTotal(message)
return { return {
totalCost, message,
context: { provider,
message, model,
provider, providerLabel: provider?.name ?? message.providerID,
model, modelLabel: model?.name ?? message.modelID,
providerLabel: provider?.name ?? message.providerID, limit,
modelLabel: model?.name ?? message.modelID, input: message.tokens.input,
limit, usage: limit ? Math.round((total / limit) * 100) : null,
input: message.tokens.input,
output: message.tokens.output,
reasoning: message.tokens.reasoning,
cacheRead: message.tokens.cache.read,
cacheWrite: message.tokens.cache.write,
total,
usage: limit ? Math.round((total / limit) * 100) : null,
},
} }
} }
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers) return build(messages, providers)
} }
export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) {
if (!tokens) return undefined
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
}

View file

@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format" import { createSessionContextFormatter } from "./session-context-format"
@ -134,12 +134,12 @@ export function SessionContextTab() {
}), }),
) )
const metrics = createMemo(() => getSessionContextMetrics(messages(), [...providers.all().values()])) const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
const ctx = createMemo(() => metrics().context) const tokens = createMemo(() => info()?.tokens)
const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(info()?.cost ?? 0)
}) })
const counts = createMemo(() => { const counts = createMemo(() => {
@ -204,14 +204,14 @@ export function SessionContextTab() {
{ label: "context.stats.provider", value: providerLabel }, { label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel }, { label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) }, { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) },
{ {
label: "context.stats.cacheTokens", label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
}, },
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },