Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.5",
"packageManager": "bun@1.3.6",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
Expand Down
214 changes: 206 additions & 8 deletions packages/app/src/components/session-context-usage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Match, Show, Switch, createMemo } from "solid-js"
import { For, Match, Show, Switch, createMemo } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
Expand All @@ -12,6 +12,74 @@ interface SessionContextUsageProps {
variant?: "button" | "indicator"
}

interface ProviderUsage {
provider: string
tokens: number
cost: number
requests: number
percentage: number
}

interface ModelUsage {
model: string
provider: string
input: number
output: number
cost: number
requests: number
total: number
}

function getProviderEmoji(provider: string): string {
const p = provider.toLowerCase()
if (p.includes("anthropic")) return "🟣"
if (p.includes("google")) return "πŸ”΅"
if (p.includes("openai")) return "🟒"
if (p.includes("antigravity")) return "🌌"
if (p.includes("xai") || p.includes("grok")) return "⚑"
if (p.includes("groq")) return "🟠"
if (p.includes("mistral")) return "πŸ”Ά"
if (p.includes("deepseek")) return "πŸ‹"
return "βšͺ"
}

function shortenModelName(model: string): string {
return model
.replace("claude-", "")
.replace("gpt-", "")
.replace("gemini-", "")
.replace("-latest", "")
.replace("-20250514", "")
.replace("-20250219", "")
.replace("-20241022", "")
.replace("-20240620", "")
}

function formatCost(n: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 4,
maximumFractionDigits: 4,
}).format(n)
}

function getProviderLabel(provider: string): string {
const p = provider.toLowerCase()
if (p.includes("anthropic")) return "Anthropic"
if (p.includes("google")) return "Google"
if (p.includes("openai")) return "OpenAI"
if (p.includes("antigravity")) return "Antigravity"
if (p.includes("xai") || p.includes("grok")) return "xAI"
return provider
}

function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
return tokens.toString()
}

export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const params = useParams()
Expand All @@ -30,6 +98,83 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}).format(total)
})

// Per-provider usage breakdown
const providerUsage = createMemo((): ProviderUsage[] => {
const byProvider = new Map<string, { tokens: number; cost: number; requests: number }>()

for (const msg of messages()) {
if (msg.role !== "assistant") continue
const assistant = msg as AssistantMessage
const providerId = assistant.providerID
if (!providerId) continue

const tokens = assistant.tokens.input + assistant.tokens.output +
assistant.tokens.reasoning + assistant.tokens.cache.read +
assistant.tokens.cache.write

const existing = byProvider.get(providerId) || { tokens: 0, cost: 0, requests: 0 }
existing.tokens += tokens
existing.cost += assistant.cost || 0
existing.requests += 1
byProvider.set(providerId, existing)
}

const totalTokens = Array.from(byProvider.values()).reduce((sum, x) => sum + x.tokens, 0)

const result: ProviderUsage[] = []
for (const [provider, usage] of byProvider) {
result.push({
provider,
tokens: usage.tokens,
cost: usage.cost,
requests: usage.requests,
percentage: totalTokens > 0 ? (usage.tokens / totalTokens) * 100 : 0,
})
}

return result.sort((a, b) => b.tokens - a.tokens)
})

// Per-model usage breakdown
const modelUsage = createMemo((): ModelUsage[] => {
const byModel = new Map<string, { provider: string; input: number; output: number; cost: number; requests: number }>()

for (const msg of messages()) {
if (msg.role !== "assistant") continue
const assistant = msg as AssistantMessage
const providerId = assistant.providerID || "unknown"
const modelId = assistant.modelID || "unknown"
const key = `${providerId}:${modelId}`

const existing = byModel.get(key) || { provider: providerId, input: 0, output: 0, cost: 0, requests: 0 }
existing.input += assistant.tokens.input + assistant.tokens.cache.read
existing.output += assistant.tokens.output + assistant.tokens.reasoning
existing.cost += assistant.cost || 0
existing.requests += 1
byModel.set(key, existing)
}

const result: ModelUsage[] = []
for (const [key, usage] of byModel) {
const modelId = key.split(":").slice(1).join(":")
result.push({
model: modelId,
provider: usage.provider,
input: usage.input,
output: usage.output,
cost: usage.cost,
requests: usage.requests,
total: usage.input + usage.output,
})
}

return result.sort((a, b) => b.total - a.total)
})

const totalTokens = createMemo(() => {
return providerUsage().reduce((sum, x) => sum + x.tokens, 0)
})

const context = createMemo(() => {
const last = messages().findLast((x) => {
if (x.role !== "assistant") return false
Expand Down Expand Up @@ -60,27 +205,80 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
)

const tooltipValue = () => (
<div>
<div class="min-w-48">
{/* Provider Usage Breakdown */}
<Show when={providerUsage().length > 0}>
<div class="mb-2 pb-2 border-b border-border-base/30">
<div class="text-11-medium text-text-invert-base mb-1.5">Provider Usage</div>
<For each={providerUsage()}>
{(usage) => (
<div class="flex items-center justify-between gap-3 py-0.5">
<div class="flex items-center gap-1.5">
<span>{getProviderEmoji(usage.provider)}</span>
<span class="text-text-invert-base text-11-regular">{getProviderLabel(usage.provider)}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong text-11-medium">{formatTokens(usage.tokens)}</span>
<span class="text-text-invert-weak text-10-regular w-8 text-right">{Math.round(usage.percentage)}%</span>
</div>
</div>
)}
</For>
<div class="flex items-center justify-between gap-3 pt-1 mt-1 border-t border-border-base/20">
<span class="text-text-invert-base text-11-medium">Total</span>
<span class="text-text-invert-strong text-11-medium">{formatTokens(totalTokens())}</span>
</div>
</div>
</Show>

{/* Model Usage Breakdown */}
<Show when={modelUsage().length > 0}>
<div class="mb-2 pb-2 border-b border-border-base/30">
<div class="text-11-medium text-text-invert-base mb-1.5">Model Usage</div>
<For each={modelUsage()}>
{(usage) => (
<div class="py-0.5">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-1.5">
<span>{getProviderEmoji(usage.provider)}</span>
<span class="text-text-invert-base text-11-regular truncate max-w-32">{shortenModelName(usage.model)}</span>
</div>
<span class="text-text-invert-weak text-10-regular">{usage.requests}x</span>
</div>
<div class="flex items-center justify-between gap-3 pl-5">
<span class="text-text-invert-weak text-10-regular">↑{formatTokens(usage.input)} ↓{formatTokens(usage.output)}</span>
<span class="text-text-invert-strong text-10-medium">{formatCost(usage.cost)}</span>
</div>
</div>
)}
</For>
</div>
</Show>

{/* Context Window Usage */}
<Show when={context()}>
{(ctx) => (
<>
<div class="mb-1">
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().tokens}</span>
<span class="text-text-invert-base">Tokens</span>
<span class="text-text-invert-base">Context Tokens</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
<span class="text-text-invert-base">Usage</span>
<span class="text-text-invert-base">Window Usage</span>
</div>
</>
</div>
)}
</Show>

{/* Cost */}
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">Cost</span>
<span class="text-text-invert-base">Total Cost</span>
</div>

<Show when={variant() === "button"}>
<div class="text-11-regular text-text-invert-base mt-1">Click to view context</div>
<div class="text-11-regular text-text-invert-base mt-2 pt-1 border-t border-border-base/20">Click to view context</div>
</Show>
</div>
)
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { SessionContextUsage } from "@/components/session-context-usage"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { same } from "@/utils/same"

Expand Down Expand Up @@ -161,6 +162,7 @@ export function SessionHeader() {
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionContextUsage />
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
Expand Down
5 changes: 1 addition & 4 deletions packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,7 @@ export function Session() {
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<Show when={!sidebarVisible()}>
<Show when={!sidebarVisible() || !wide()}>
<Header />
</Show>
<scrollbox
Expand Down Expand Up @@ -1028,9 +1028,6 @@ export function Session() {
sessionID={route.sessionID}
/>
</box>
<Show when={!sidebarVisible()}>
<Footer />
</Show>
</Show>
<Toast />
</box>
Expand Down
Loading