Skip to content
Merged
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
12 changes: 12 additions & 0 deletions packages/app/src/components/session-context-usage-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type ContextUsageTone = "normal" | "warning" | "danger"

export function contextUsageTone(usage: number | null | undefined): ContextUsageTone {
if (usage === undefined || usage === null) return "normal"
if (usage >= 90) return "danger"
if (usage >= 70) return "warning"
return "normal"
}

export function contextUsageRingPercent(usage: number | null | undefined) {
return Math.max(0, Math.min(100, usage ?? 0))
}
22 changes: 22 additions & 0 deletions packages/app/src/components/session-context-usage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { contextUsageRingPercent, contextUsageTone } from "./session-context-usage-state"

describe("session context usage indicator helpers", () => {
test("uses normal tone for unknown usage and usage below warning", () => {
expect(contextUsageTone(null)).toBe("normal")
expect(contextUsageTone(69.9)).toBe("normal")
})

test("uses warning and danger thresholds", () => {
expect(contextUsageTone(70)).toBe("warning")
expect(contextUsageTone(89.9)).toBe("warning")
expect(contextUsageTone(90)).toBe("danger")
})

test("clamps only ring drawing percentage", () => {
expect(contextUsageRingPercent(null)).toBe(0)
expect(contextUsageRingPercent(-1)).toBe(0)
expect(contextUsageRingPercent(42.5)).toBe(42.5)
expect(contextUsageRingPercent(120)).toBe(100)
})
})
45 changes: 38 additions & 7 deletions packages/app/src/components/session-context-usage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useLanguage } from "@/context/language"
import { useProviders } from "@/hooks/use-providers"
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
import { useSessionLayout } from "@/pages/session/session-layout"
import { contextUsageRingPercent, contextUsageTone } from "./session-context-usage-state"

interface SessionContextUsageProps {
variant?: "button" | "indicator"
Expand All @@ -23,8 +24,14 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const variant = createMemo(() => props.variant ?? "button")
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))

const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all(), sync.data.config))
const context = createMemo(() => metrics().context)
const tone = createMemo(() => contextUsageTone(context()?.usagePercent))
const ringColor = createMemo(() => {
if (tone() === "danger") return "var(--icon-error-base)"
if (tone() === "warning") return "var(--icon-warning-base)"
return "var(--border-active)"
})
const cost = createMemo(() =>
new Intl.NumberFormat(language.intl(), {
style: "currency",
Expand All @@ -38,24 +45,48 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}

const circle = () => (
<div class="flex items-center justify-center">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.usage ?? 0} />
<div class="flex items-center justify-center" style={{ "--progress-circle-progress": ringColor() }}>
<ProgressCircle size={16} strokeWidth={2} percentage={contextUsageRingPercent(context()?.usagePercent)} />
</div>
)

const compactStatus = () => {
const ctx = context()
if (!ctx) return undefined
if (!ctx.autoCompactEnabled) return language.t("context.usage.autoCompactOff")
if (ctx.effectiveInputLimit === undefined) return language.t("context.usage.limitUnknown")
if (ctx.compactThreshold === undefined) return undefined
return language.t("context.usage.autoCompactsAround", {
threshold: ctx.compactThreshold.toLocaleString(language.intl()),
})
}

const contextUsedText = (ctx: NonNullable<ReturnType<typeof context>>) => {
const used = ctx.usedTokens.toLocaleString(language.intl())
const limit = ctx.effectiveInputLimit
if (limit === undefined) {
return language.t("context.usage.contextUsedUnknownLimit", { used })
}
return language.t("context.usage.contextUsedWithLimit", {
used,
limit: limit.toLocaleString(language.intl()),
})
}

const tooltipValue = () => (
<div>
<Show when={context()}>
{(ctx) => (
<>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().total.toLocaleString(language.intl())}</span>
<span class="text-text-invert-base">{language.t("context.usage.tokens")}</span>
<span class="text-text-invert-strong">{language.t("context.usage.title")}</span>
</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>
<span class="text-text-invert-strong">{contextUsedText(ctx())}</span>
</div>
<Show when={compactStatus()}>
{(status) => <div class="text-text-invert-base">{status()}</div>}
</Show>
</>
)}
</Show>
Expand Down
101 changes: 96 additions & 5 deletions packages/app/src/components/session/session-context-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getSessionContextMetrics } from "./session-context-metrics"

const assistant = (
id: string,
tokens: { input: number; output: number; reasoning: number; read: number; write: number },
tokens: { total?: number; input: number; output: number; reasoning: number; read: number; write: number },
cost: number,
providerID = "openai",
modelID = "gpt-4.1",
Expand All @@ -16,6 +16,7 @@ const assistant = (
modelID,
cost,
tokens: {
total: tokens.total,
input: tokens.input,
output: tokens.output,
reasoning: tokens.reasoning,
Expand Down Expand Up @@ -51,7 +52,7 @@ describe("getSessionContextMetrics", () => {
models: {
"gpt-4.1": {
name: "GPT-4.1",
limit: { context: 1000 },
limit: { context: 1000, output: 100 },
},
},
},
Expand All @@ -61,12 +62,101 @@ describe("getSessionContextMetrics", () => {

expect(metrics.totalCost).toBe(1.75)
expect(metrics.context?.message.id).toBe("a2")
expect(metrics.context?.total).toBe(500)
expect(metrics.context?.usage).toBe(50)
expect(metrics.context?.total).toBe(450)
expect(metrics.context?.usedTokens).toBe(450)
expect(metrics.context?.effectiveInputLimit).toBe(1000)
expect(metrics.context?.compactThreshold).toBe(900)
expect(metrics.context?.usagePercent).toBe(45)
expect(metrics.context?.usage).toBe(45)
expect(metrics.context?.providerLabel).toBe("OpenAI")
expect(metrics.context?.modelLabel).toBe("GPT-4.1")
})

test("uses input limit and custom compaction reserve for usage metrics", () => {
const messages = [assistant("a1", { total: 238_000, input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 1)]
const providers = [
{
id: "openai",
models: {
"gpt-4.1": {
limit: { context: 400_000, input: 272_000, output: 128_000 },
},
},
},
]

const metrics = getSessionContextMetrics(messages, providers, { compaction: { reserved: 20_000 } })

expect(metrics.context?.effectiveInputLimit).toBe(272_000)
expect(metrics.context?.contextWindow).toBe(400_000)
expect(metrics.context?.usedTokens).toBe(238_000)
expect(metrics.context?.compactThreshold).toBe(252_000)
expect(metrics.context?.usagePercent).toBeCloseTo((238_000 / 272_000) * 100, 5)
expect(metrics.context?.usage).toBe(Math.round((238_000 / 272_000) * 100))
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("keeps raw usage separate from rounded display usage", () => {
const messages = [assistant("a1", { total: 696, input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 1)]
const providers = [
{
id: "openai",
models: {
"gpt-4.1": {
limit: { context: 1_000, output: 100 },
},
},
},
]

const metrics = getSessionContextMetrics(messages, providers)

expect(metrics.context?.usagePercent).toBe(69.6)
expect(metrics.context?.usage).toBe(70)
})

test("selects the latest assistant when only total tokens are reported", () => {
const messages = [
assistant("a1", { input: 10, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1),
assistant("a2", { total: 70_000, input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.2),
]
const providers = [
{
id: "openai",
models: {
"gpt-4.1": {
limit: { context: 100_000, output: 10_000 },
},
},
},
]

const metrics = getSessionContextMetrics(messages, providers)

expect(metrics.context?.message.id).toBe("a2")
expect(metrics.context?.usedTokens).toBe(70_000)
})

test("treats zero context limit as unknown", () => {
const messages = [assistant("a1", { total: 20_000, input: 1, output: 1, reasoning: 1, read: 1, write: 1 }, 1)]
const providers = [
{
id: "openai",
models: {
"gpt-4.1": {
limit: { context: 0, output: 0 },
},
},
},
]

const metrics = getSessionContextMetrics(messages, providers)

expect(metrics.context?.usedTokens).toBe(20_000)
expect(metrics.context?.effectiveInputLimit).toBeUndefined()
expect(metrics.context?.compactThreshold).toBeUndefined()
expect(metrics.context?.usage).toBeNull()
})

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 providers = [{ id: "p-1", models: {} }]
Expand All @@ -75,7 +165,8 @@ describe("getSessionContextMetrics", () => {

expect(metrics.context?.providerLabel).toBe("p-1")
expect(metrics.context?.modelLabel).toBe("m-1")
expect(metrics.context?.limit).toBeUndefined()
expect(metrics.context?.effectiveInputLimit).toBeUndefined()
expect(metrics.context?.compactThreshold).toBeUndefined()
expect(metrics.context?.usage).toBeNull()
})

Expand Down
45 changes: 37 additions & 8 deletions packages/app/src/components/session/session-context-metrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
import {
contextUsageModelOutputLimit,
contextUsageUsedTokens,
deriveContextUsage,
} from "@opencode-ai/util/context-usage"

type Provider = {
id: string
Expand All @@ -10,6 +15,15 @@ type Model = {
name?: string
limit: {
context: number
input?: number
output?: number
}
}

type Config = {
compaction?: {
auto?: boolean
reserved?: number
}
}

Expand All @@ -19,13 +33,18 @@ type Context = {
model?: Model
providerLabel: string
modelLabel: string
limit: number | undefined
effectiveInputLimit: number | undefined
contextWindow: number | undefined
compactThreshold: number | undefined
autoCompactEnabled: boolean
usedTokens: number
input: number
output: number
reasoning: number
cacheRead: number
cacheWrite: number
total: number
usagePercent: number | null
usage: number | null
}

Expand All @@ -35,7 +54,7 @@ type Metrics = {
}

const tokenTotal = (msg: AssistantMessage) => {
return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
return contextUsageUsedTokens(msg.tokens)
}

const lastAssistantWithTokens = (messages: Message[]) => {
Expand All @@ -47,15 +66,20 @@ const lastAssistantWithTokens = (messages: Message[]) => {
}
}

const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const build = (messages: Message[] = [], providers: Provider[] = [], config: Config = {}): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined }

const provider = providers.find((item) => item.id === message.providerID)
const model = provider?.models[message.modelID]
const limit = model?.limit.context
const total = tokenTotal(message)
const usage = deriveContextUsage({
model,
tokens: message.tokens,
compaction: config.compaction,
defaultReserveTokens: contextUsageModelOutputLimit(model),
})

return {
totalCost,
Expand All @@ -65,18 +89,23 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
model,
providerLabel: provider?.name ?? message.providerID,
modelLabel: model?.name ?? message.modelID,
limit,
effectiveInputLimit: usage.effectiveInputLimit,
contextWindow: model?.limit.context || undefined,
compactThreshold: usage.compactThreshold,
autoCompactEnabled: usage.autoCompactEnabled,
usedTokens: usage.usedTokens,
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,
usagePercent: usage.usagePercent,
usage: usage.usagePercent === null ? null : Math.round(usage.usagePercent),
},
}
}

export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = [], config: Config = {}) {
return build(messages, providers, config)
}
15 changes: 12 additions & 3 deletions packages/app/src/components/session/session-context-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function SessionContextTab() {
}),
)

const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all(), sync.data.config))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))

Expand Down Expand Up @@ -169,6 +169,13 @@ export function SessionContextTab() {
return c.modelLabel
})

const compactThreshold = createMemo(() => {
const c = ctx()
if (!c) return "—"
if (!c.autoCompactEnabled) return language.t("context.stats.off")
return formatter().number(c.compactThreshold)
})

const breakdown = createMemo(
on(
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
Expand Down Expand Up @@ -198,8 +205,10 @@ export function SessionContextTab() {
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.contextWindow", value: () => formatter().number(ctx()?.contextWindow) },
{ label: "context.stats.inputLimit", value: () => formatter().number(ctx()?.effectiveInputLimit) },
{ label: "context.stats.compactThreshold", value: compactThreshold },
{ label: "context.stats.contextUsed", value: () => formatter().number(ctx()?.usedTokens) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
Expand Down
Loading
Loading