diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c80daf9cff5f..a0adfefffbca 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -62,6 +62,7 @@ import { type WorkspaceStatus } from "../workspace-label" import { useCommandPalette } from "../../context/command-palette" import { useBindings, useCommandShortcut, useLeaderActive, useOpencodeKeymap } from "../../keymap" import { useTuiConfig } from "../../context/tui-config" +import { SessionOverflow } from "@/session/overflow" export type PromptProps = { sessionID?: string @@ -342,15 +343,14 @@ export function Prompt(props: PromptProps) { const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) if (!last) return - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + const tokens = SessionOverflow.count(last.tokens) if (tokens <= 0) return const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] - const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined + const percent = model ? SessionOverflow.percent({ cfg: sync.data.config, tokens: last.tokens, model }) : null const cost = session?.cost ?? 0 return { - context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), + context: percent === null ? Locale.number(tokens) : `${Locale.number(tokens)} (${percent}%)`, cost: cost > 0 ? money.format(cost) : undefined, } }) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx index 405e8c1458a5..5a3d0e6cd9f0 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/sidebar/context.tsx @@ -2,6 +2,7 @@ import type { AssistantMessage } from "@opencode-ai/sdk/v2" import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { InternalTuiPlugin } from "../../plugin/internal" import { createMemo } from "solid-js" +import { SessionOverflow } from "@/session/overflow" const id = "internal:sidebar-context" @@ -25,12 +26,11 @@ 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 tokens = SessionOverflow.count(last.tokens) 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, + percent: model ? SessionOverflow.percent({ cfg: props.api.state.config, tokens: last.tokens, model }) : null, } }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx index f4a458b63dfe..9494736e5286 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/subagent-footer.tsx @@ -8,6 +8,7 @@ import { Locale } from "@/util/locale" import { useTerminalDimensions } from "@opentui/solid" import { useCommandPalette } from "../../context/command-palette" import { useCommandShortcut } from "../../keymap" +import { SessionOverflow } from "@/session/overflow" export function SubagentFooter() { const route = useRouteData("session") @@ -36,12 +37,11 @@ export function SubagentFooter() { const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0) if (!last) return - const tokens = - last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write + const tokens = SessionOverflow.count(last.tokens) if (tokens <= 0) return const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID] - const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined + const percent = model ? SessionOverflow.percent({ cfg: sync.data.config, tokens: last.tokens, model }) : null const cost = session()?.cost ?? 0 const money = new Intl.NumberFormat("en-US", { @@ -50,7 +50,7 @@ export function SubagentFooter() { }) return { - context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens), + context: percent === null ? Locale.number(tokens) : `${Locale.number(tokens)} (${percent}%)`, cost: cost > 0 ? money.format(cost) : undefined, } }) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c8dbe6117055..7a30fa50e80a 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1250,7 +1250,7 @@ export function providerOptions(model: Provider.Model, options: { [x: string]: a return { [key]: options } } -export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number { +export function maxOutputTokens(model: { limit: { output: number } }, outputTokenMax = OUTPUT_TOKEN_MAX): number { return Math.min(model.limit.output, outputTokenMax) || outputTokenMax } diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index d01fe5c624dd..09d1657f811d 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -5,7 +5,22 @@ import type { MessageV2 } from "./message-v2" const COMPACTION_BUFFER = 20_000 -export function usable(input: { cfg: Config.Info; model: Provider.Model; outputTokenMax?: number }) { +type ContextConfig = { + compaction?: { + auto?: boolean + reserved?: number + } +} + +type ContextModel = { + limit: { + context: number + input?: number + output: number + } +} + +export function usable(input: { cfg: ContextConfig; model: ContextModel; outputTokenMax?: number }) { const context = input.model.limit.context if (context === 0) return 0 @@ -17,6 +32,21 @@ export function usable(input: { cfg: Config.Info; model: Provider.Model; outputT : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model, input.outputTokenMax)) } +export function count(tokens: MessageV2.Assistant["tokens"]) { + return tokens.total || tokens.input + tokens.output + tokens.cache.read + tokens.cache.write +} + +export function percent(input: { + cfg: ContextConfig + tokens: MessageV2.Assistant["tokens"] + model: ContextModel + outputTokenMax?: number +}) { + const limit = usable(input) + if (limit === 0) return null + return Math.round((count(input.tokens) / limit) * 100) +} + export function isOverflow(input: { cfg: Config.Info tokens: MessageV2.Assistant["tokens"] @@ -26,7 +56,7 @@ export function isOverflow(input: { if (input.cfg.compaction?.auto === false) return false if (input.model.limit.context === 0) return false - const count = - input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write - return count >= usable(input) + return count(input.tokens) >= usable(input) } + +export * as SessionOverflow from "./overflow" diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2bc9b196216d..f9edfca19242 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -8,6 +8,7 @@ import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" +import { SessionOverflow } from "../../src/session/overflow" import { Token } from "@/util/token" import * as Log from "@opencode-ai/core/util/log" import { Permission } from "../../src/permission" @@ -83,6 +84,38 @@ function createModel(opts: { const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) }) +describe("session.overflow.percent", () => { + test("uses input limit minus default compaction reserve", () => { + const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) + const tokens = { input: 126_000, output: 0, reasoning: 50_000, cache: { read: 0, write: 0 } } + + expect(SessionOverflow.usable({ cfg: {}, model })).toBe(252_000) + expect(SessionOverflow.percent({ cfg: {}, tokens, model })).toBe(50) + }) + + test("uses configured compaction reserve for input-limited models", () => { + const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) + const tokens = { input: 121_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + + expect(SessionOverflow.percent({ cfg: { compaction: { reserved: 30_000 } }, tokens, model })).toBe(50) + }) + + test("returns null when there is no known context limit", () => { + const model = createModel({ context: 0, output: 32_000 }) + const tokens = { input: 10_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + + expect(SessionOverflow.percent({ cfg: {}, tokens, model })).toBe(null) + }) + + test("uses total token count when providers report it", () => { + const model = createModel({ context: 400_000, input: 272_000, output: 128_000 }) + const tokens = { total: 63_000, input: 126_000, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } + + expect(SessionOverflow.count(tokens)).toBe(63_000) + expect(SessionOverflow.percent({ cfg: {}, tokens, model })).toBe(25) + }) +}) + function createUserMessage(sessionID: SessionID, text: string) { return Effect.gen(function* () { const ssn = yield* SessionNs.Service