diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx
index f1c99d9679ce..02788f4f8c85 100644
--- a/packages/tui/src/feature-plugins/sidebar/context.tsx
+++ b/packages/tui/src/feature-plugins/sidebar/context.tsx
@@ -1,7 +1,8 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { BuiltinTuiPlugin } from "../builtins"
-import { createMemo } from "solid-js"
+import { createMemo, Show } from "solid-js"
+import { Locale } from "../../util/locale"
const id = "internal:sidebar-context"
@@ -10,6 +11,22 @@ const money = new Intl.NumberFormat("en-US", {
currency: "USD",
})
+export function computeContextState(
+ last: AssistantMessage,
+ contextLimit?: number,
+) {
+ const tokens =
+ last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+ const totalInput = last.tokens.input + last.tokens.cache.read
+ return {
+ tokens,
+ input: last.tokens.input,
+ cache: last.tokens.cache.read,
+ cachePercent: totalInput > 0 ? Math.round(last.tokens.cache.read / totalInput * 100) : 0,
+ percent: contextLimit ? Math.round((tokens / contextLimit) * 100) : null,
+ }
+}
+
function View(props: { api: TuiPluginApi; session_id: string }) {
const theme = () => props.api.theme.current
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
@@ -21,17 +38,15 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
if (!last) {
return {
tokens: 0,
+ input: 0,
+ cache: 0,
+ cachePercent: null,
percent: null,
}
}
- const tokens =
- last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
- return {
- tokens,
- percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
- }
+ return computeContextState(last, model?.limit.context)
})
return (
@@ -39,8 +54,11 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
Context
- {state().tokens.toLocaleString()} tokens
- {state().percent ?? 0}% used
+ {state().tokens.toLocaleString()} tokens ({state().percent ?? 0}%)
+ 0 || state().cache > 0}>
+ Input {Locale.number(state().input)} ยท Cache {Locale.number(state().cache)}
+ {state().cachePercent}% cached
+
{money.format(cost())} spent
)
diff --git a/packages/tui/test/feature-plugins/sidebar-context.test.ts b/packages/tui/test/feature-plugins/sidebar-context.test.ts
new file mode 100644
index 000000000000..7f8326b10070
--- /dev/null
+++ b/packages/tui/test/feature-plugins/sidebar-context.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, test } from "bun:test"
+import type { AssistantMessage } from "@opencode-ai/sdk/v2"
+import { computeContextState } from "../../src/feature-plugins/sidebar/context"
+
+function makeMessage(overrides: Partial = {}): AssistantMessage {
+ return {
+ id: "msg_1",
+ sessionID: "ses_1",
+ role: "assistant",
+ agent: "build",
+ modelID: "claude-sonnet-4-20250514",
+ providerID: "anthropic",
+ mode: "",
+ parentID: "msg_0",
+ path: { cwd: "/test", root: "/test" },
+ cost: 0,
+ tokens: {
+ input: 0,
+ output: 0,
+ reasoning: 0,
+ cache: { read: 0, write: 0 },
+ ...overrides,
+ },
+ time: { created: 0 },
+ }
+}
+
+describe("sidebar context cache calculation", () => {
+ test("returns 0% cache when no cache read tokens", () => {
+ const msg = makeMessage({ input: 1000, output: 500, cache: { read: 0, write: 0 } })
+ const state = computeContextState(msg)
+ expect(state.cachePercent).toBe(0)
+ expect(state.input).toBe(1000)
+ expect(state.cache).toBe(0)
+ })
+
+ test("calculates cache hit percentage correctly", () => {
+ const msg = makeMessage({ input: 800, output: 500, cache: { read: 200, write: 0 } })
+ const state = computeContextState(msg)
+ expect(state.input).toBe(800)
+ expect(state.cache).toBe(200)
+ expect(state.cachePercent).toBe(20)
+ })
+
+ test("100% cache hit when all input is cached", () => {
+ const msg = makeMessage({ input: 0, output: 500, cache: { read: 1000, write: 0 } })
+ const state = computeContextState(msg)
+ expect(state.input).toBe(0)
+ expect(state.cache).toBe(1000)
+ expect(state.cachePercent).toBe(100)
+ })
+
+ test("does not include cache.write in cache hit calculation", () => {
+ const msg = makeMessage({ input: 800, output: 500, cache: { read: 200, write: 500 } })
+ const state = computeContextState(msg)
+ expect(state.cache).toBe(200)
+ expect(state.cachePercent).toBe(20)
+ })
+
+ test("cache.write is included in total token count", () => {
+ const msg = makeMessage({ input: 800, output: 500, reasoning: 100, cache: { read: 200, write: 400 } })
+ const state = computeContextState(msg)
+ expect(state.tokens).toBe(800 + 500 + 100 + 200 + 400)
+ })
+
+ test("handles zero input and zero cache gracefully", () => {
+ const msg = makeMessage({ input: 0, output: 0, cache: { read: 0, write: 0 } })
+ const state = computeContextState(msg)
+ expect(state.cachePercent).toBe(0)
+ expect(state.input).toBe(0)
+ expect(state.cache).toBe(0)
+ })
+
+ test("rounds cache percentage to nearest integer", () => {
+ const msg = makeMessage({ input: 1000, output: 500, cache: { read: 333, write: 0 } })
+ const state = computeContextState(msg)
+ expect(state.cachePercent).toBe(25)
+ })
+
+ test("50% cache hit", () => {
+ const msg = makeMessage({ input: 500, output: 500, cache: { read: 500, write: 0 } })
+ const state = computeContextState(msg)
+ expect(state.cachePercent).toBe(50)
+ })
+
+ test("calculates context percentage when limit provided", () => {
+ const msg = makeMessage({ input: 1000, output: 500, cache: { read: 0, write: 0 } })
+ const state = computeContextState(msg, 200000)
+ expect(state.percent).toBe(1)
+ })
+
+ test("returns null percent when no context limit", () => {
+ const msg = makeMessage({ input: 1000, output: 500 })
+ const state = computeContextState(msg)
+ expect(state.percent).toBeNull()
+ })
+})