From 6a6ec8de00a8168f42f83014581b2e23f65b661c Mon Sep 17 00:00:00 2001 From: walker Date: Fri, 26 Jun 2026 00:27:52 +0800 Subject: [PATCH 1/2] feat(tui): display input/cache tokens with cache percentage in context panel Show breakdown of non-cached input tokens vs cached tokens in the right-side Context panel. The cache percentage (cache / total input) helps users instantly gauge how much their API calls benefit from cache pricing. --- .../tui/src/feature-plugins/sidebar/context.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index f1c99d9679ce..46fe78b3c7b7 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" @@ -21,6 +22,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) { if (!last) { return { tokens: 0, + input: 0, + cache: 0, + cachePercent: null, percent: null, } } @@ -28,8 +32,12 @@ function View(props: { api: TuiPluginApi; session_id: string }) { 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] + const totalInput = last.tokens.input + last.tokens.cache.read + last.tokens.cache.write return { tokens, + input: last.tokens.input, + cache: last.tokens.cache.read + last.tokens.cache.write, + cachePercent: totalInput > 0 ? Math.round((last.tokens.cache.read + last.tokens.cache.write) / totalInput * 100) : 0, percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, } }) @@ -39,8 +47,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 ) From f805f6ea897181dea36e274a72a021ade1c0586f Mon Sep 17 00:00:00 2001 From: walker Date: Fri, 26 Jun 2026 19:13:35 +0800 Subject: [PATCH 2/2] fix(tui): correct cache hit percentage to exclude cache.write - Extract computeContextState function for testability - Cache hit percentage now correctly uses only cache.read tokens - cache.write (cache misses) excluded from hit ratio calculation - Add comprehensive unit tests for cache computation --- .../src/feature-plugins/sidebar/context.tsx | 27 ++++-- .../feature-plugins/sidebar-context.test.ts | 97 +++++++++++++++++++ 2 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 packages/tui/test/feature-plugins/sidebar-context.test.ts diff --git a/packages/tui/src/feature-plugins/sidebar/context.tsx b/packages/tui/src/feature-plugins/sidebar/context.tsx index 46fe78b3c7b7..02788f4f8c85 100644 --- a/packages/tui/src/feature-plugins/sidebar/context.tsx +++ b/packages/tui/src/feature-plugins/sidebar/context.tsx @@ -11,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)) @@ -29,17 +45,8 @@ function View(props: { api: TuiPluginApi; session_id: string }) { } } - 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] - const totalInput = last.tokens.input + last.tokens.cache.read + last.tokens.cache.write - return { - tokens, - input: last.tokens.input, - cache: last.tokens.cache.read + last.tokens.cache.write, - cachePercent: totalInput > 0 ? Math.round((last.tokens.cache.read + last.tokens.cache.write) / totalInput * 100) : 0, - percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null, - } + return computeContextState(last, model?.limit.context) }) return ( 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() + }) +})