diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e76cc30..349fb84d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ All notable changes to this project will be documented in this file. - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). - Chat: saved chats in the context panel open again instead of staying blank. +- Chat: the context meter no longer climbs over 100% (330% readouts) after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds, everywhere the value appears — header, context sidebar, work status panel, mini chat, and mobile (thanks to @pocharlies). - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude` in the sidebar, window title, settings and notifications; names you renamed yourself are kept. - Settings: the session retention action you pick is now saved instead of being dropped (thanks to @Gautam0507). - Browser: typing a comment on a page no longer triggers app shortcuts. diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index 9ffe0d66..3d239d1f 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -388,6 +388,7 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { tokens?: { + total?: unknown; input?: unknown; output?: unknown; reasoning?: unknown; @@ -395,6 +396,11 @@ export const MobileSessionMetadataButton = React.memo(function MobileSessionMeta }; }; if (message.role !== 'assistant' || !message.tokens) continue; + // Multi-step turns accumulate the fields across API round-trips, so + // summing them overstates the window. The server-reported total is the + // final round-trip's window; sum only when the server did not send it. + const reportedTotal = getTokenCount(message.tokens.total); + if (reportedTotal > 0) return reportedTotal; const total = getTokenCount(message.tokens.input) + getTokenCount(message.tokens.output) + getTokenCount(message.tokens.reasoning) diff --git a/packages/ui/src/components/chat/work-status/contextUsage.test.ts b/packages/ui/src/components/chat/work-status/contextUsage.test.ts index c2e0a6e4..34f538f6 100644 --- a/packages/ui/src/components/chat/work-status/contextUsage.test.ts +++ b/packages/ui/src/components/chat/work-status/contextUsage.test.ts @@ -14,8 +14,8 @@ describe('computeContextUsage', () => { }); test('reports the latest turn rather than a sum across turns', () => { - // Each assistant turn reports the whole window it saw, so adding them up - // would report several times the real fill. + // A turn's tokens describe that turn's window, so adding turns up would + // report several times the real fill. const usage = computeContextUsage( [ assistant({ input: 400, output: 0, reasoning: 0 }, 'old'), @@ -61,4 +61,24 @@ describe('computeContextUsage', () => { const usage = computeContextUsage([assistant({ input: 10 })], 100); expect(usage?.totalTokens).toBe(10); }); + + test('prefers the server-reported total over summing round-trip fields', () => { + // Real payload from opencode 1.18.18: ~14 tool-call round-trips accumulated + // cache.read to 3.29M while the 1M window really held 232,872. Summing + // rendered 330.6%; the reported total renders the real 23.3%. + const usage = computeContextUsage( + [assistant({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } })], + 1_000_000, + ); + expect(usage?.totalTokens).toBe(232_872); + expect(usage?.percent.toFixed(4)).toBe('23.2872'); + }); + + test('selects a message whose only signal is the reported total', () => { + const usage = computeContextUsage( + [assistant({ total: 5_000, input: 0, output: 0, reasoning: 0 })], + 100_000, + ); + expect(usage?.totalTokens).toBe(5_000); + }); }); diff --git a/packages/ui/src/components/chat/work-status/contextUsage.ts b/packages/ui/src/components/chat/work-status/contextUsage.ts index c30d57f6..920d6963 100644 --- a/packages/ui/src/components/chat/work-status/contextUsage.ts +++ b/packages/ui/src/components/chat/work-status/contextUsage.ts @@ -13,7 +13,11 @@ * global read to race with. */ +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; + type MessageTokens = { + /** Server-reported window of the turn's final round-trip; absent on older servers. */ + total?: number; input?: number; output?: number; reasoning?: number; @@ -37,18 +41,12 @@ type WorkStatusContextUsage = { /** The store's own fallback when a model exposes no context limit. */ export const DEFAULT_CONTEXT_LIMIT = 200_000; -const sumTokens = (tokens: MessageTokens): number => ( - (tokens.input ?? 0) - + (tokens.output ?? 0) - + (tokens.reasoning ?? 0) - + (tokens.cache?.read ?? 0) - + (tokens.cache?.write ?? 0) -); - /** * Usage from the newest assistant message that reported a non-zero token count. - * Each assistant turn reports the whole window it saw, so the latest one is the - * current fill — not a sum across turns. + * The latest turn describes the current fill — not a sum across turns. Within + * a turn, the server-reported `total` is the final round-trip's window; + * summing the breakdown fields instead overstates multi-step turns, whose + * input/cache fields accumulate across round-trips. */ export const computeContextUsage = ( messages: readonly MessageLike[], @@ -60,7 +58,7 @@ export const computeContextUsage = ( const message = messages[index]; if (message?.role !== 'assistant' || !message.tokens) continue; - const totalTokens = sumTokens(message.tokens); + const totalTokens = contextTokensFromBreakdown(message.tokens); if (totalTokens <= 0) continue; const limit = contextLimit > 0 ? contextLimit : DEFAULT_CONTEXT_LIMIT; diff --git a/packages/ui/src/components/layout/ContextSidebarTab.tsx b/packages/ui/src/components/layout/ContextSidebarTab.tsx index 1e08bd0b..3f41e331 100644 --- a/packages/ui/src/components/layout/ContextSidebarTab.tsx +++ b/packages/ui/src/components/layout/ContextSidebarTab.tsx @@ -92,6 +92,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { } const breakdown = source as { + total?: unknown; input?: unknown; output?: unknown; reasoning?: unknown; @@ -103,6 +104,10 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { const reasoning = toNonNegativeNumber(breakdown.reasoning); const cacheRead = toNonNegativeNumber(breakdown.cache?.read); const cacheWrite = toNonNegativeNumber(breakdown.cache?.write); + // Multi-step turns accumulate the fields across API round-trips (every tool + // call re-reads the whole cached prompt), so summing them overstates the + // window. The server-reported total is the final round-trip's window. + const reportedTotal = toNonNegativeNumber(breakdown.total); return { input, @@ -110,7 +115,7 @@ const extractTokenBreakdown = (message: SessionMessage): TokenBreakdown => { reasoning, cacheRead, cacheWrite, - total: input + output + reasoning + cacheRead + cacheWrite, + total: reportedTotal > 0 ? reportedTotal : input + output + reasoning + cacheRead + cacheWrite, }; }; diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index de1f1772..0b592fe0 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -8,6 +8,7 @@ import { useViewportStore } from '@/sync/viewport-store'; import { useSessions, useDirectorySync, useSessionMessages, useSessionMessagesResolved } from '@/sync/sync-context'; import { useConfigStore } from '@/stores/useConfigStore'; import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { McpDropdown } from '@/components/mcp/McpDropdown'; import { ArchiveAllDropdown } from '@/components/session/ArchiveAllDropdown'; @@ -702,7 +703,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on } if (!lastTokens && message.tokens) { - const total = message.tokens.input + message.tokens.output + message.tokens.reasoning + (message.tokens.cache?.read ?? 0) + (message.tokens.cache?.write ?? 0); + const total = contextTokensFromBreakdown(message.tokens); if (total > 0) { lastTokens = message.tokens; lastMessageId = (currentSessionMessages[i] as { id?: string }).id; @@ -730,7 +731,7 @@ const VSCodeHeader: React.FC = ({ title, showBack, onBack, on } const lastTokens = headerMessageSummary.lastTokens; - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0); + const totalTokens = contextTokensFromBreakdown(lastTokens); const thresholdLimit = contextLimit > 0 ? contextLimit : 200000; const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0; const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined; diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index 4eea6d8f..a32779a1 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -18,6 +18,7 @@ import { useGitBranchLabel, useGitStore } from '@/stores/useGitStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { Icon } from "@/components/icon/Icon"; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; type MiniChatMode = 'session' | 'draft'; @@ -157,7 +158,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; } - type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } }; + type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } }; let lastTokens: AssistantTokens | undefined; let lastMessageId: string | undefined; @@ -166,7 +167,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { if (message.role !== 'assistant') continue; const tokens = (message as { tokens?: AssistantTokens }).tokens; if (!tokens) continue; - const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0); + const total = contextTokensFromBreakdown(tokens); if (total > 0) { lastTokens = tokens; lastMessageId = message.id; @@ -178,7 +179,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; } - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0); + const totalTokens = contextTokensFromBreakdown(lastTokens); const thresholdLimit = contextLimit > 0 ? contextLimit : 200000; const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0; const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined; diff --git a/packages/ui/src/stores/utils/tokenUtils.test.ts b/packages/ui/src/stores/utils/tokenUtils.test.ts index 63d8cf6d..494fa711 100644 --- a/packages/ui/src/stores/utils/tokenUtils.test.ts +++ b/packages/ui/src/stores/utils/tokenUtils.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test" -import { computeCacheHitRate, sumTokenBreakdown } from "./tokenUtils" +import type { Message, Part } from "@opencode-ai/sdk/v2" +import { computeCacheHitRate, contextTokensFromBreakdown, extractTokensFromMessage, sumTokenBreakdown } from "./tokenUtils" + +const assistantMessage = (tokens: unknown): { info: Message; parts: Part[] } => ({ + info: { tokens } as unknown as Message, + parts: [], +}) describe("computeCacheHitRate", () => { test("returns zero and hasInput=false for null input", () => { @@ -95,3 +101,67 @@ describe("sumTokenBreakdown (regression)", () => { expect(sumTokenBreakdown(undefined)).toBe(0) }) }) + +describe("contextTokensFromBreakdown", () => { + test("prefers the server-reported total over the summed fields", () => { + const breakdown = { total: 500, input: 100, output: 50, reasoning: 20, cache: { read: 800, write: 20 } } + expect(contextTokensFromBreakdown(breakdown)).toBe(500) + }) + + test("real multi-step turn: summing overstates a 1M window 14x, the total matches it", () => { + // Captured from opencode 1.18.18 (/session/:id/message) after a turn with + // ~14 tool-call round-trips. Every round-trip re-reads the whole cached + // prompt, so cache.read accumulates to 3.29M while the window really held + // 232,872. Summing rendered the context meter at 330.6% of a 1M window. + const breakdown = { total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } } + expect(contextTokensFromBreakdown(breakdown)).toBe(232_872) + expect(sumTokenBreakdown(breakdown)).toBe(3_306_479) + }) + + test("single-step turn: the total and the summed fields agree", () => { + // Captured from the same server: one round-trip, nothing accumulates. + const breakdown = { total: 117_714, input: 1_116, output: 87, reasoning: 543, cache: { read: 115_968, write: 0 } } + expect(contextTokensFromBreakdown(breakdown)).toBe(sumTokenBreakdown(breakdown)) + }) + + test("falls back to summing when the server sends no total (older servers)", () => { + expect(contextTokensFromBreakdown({ input: 100, output: 50, reasoning: 20, cache: { read: 80, write: 20 } })).toBe(270) + }) + + test("falls back to summing when the total is zero or not a finite number", () => { + expect(contextTokensFromBreakdown({ total: 0, input: 40 })).toBe(40) + expect(contextTokensFromBreakdown({ total: Number.NaN, input: 40 })).toBe(40) + }) + + test("handles null and undefined", () => { + expect(contextTokensFromBreakdown(null)).toBe(0) + expect(contextTokensFromBreakdown(undefined)).toBe(0) + }) +}) + +describe("extractTokensFromMessage", () => { + test("uses the reported total from the message info breakdown", () => { + const message = assistantMessage({ total: 232_872, input: 0, output: 14_523, reasoning: 0, cache: { read: 3_291_956, write: 0 } }) + expect(extractTokensFromMessage(message)).toBe(232_872) + }) + + test("sums the info breakdown when no total is reported", () => { + expect(extractTokensFromMessage(assistantMessage({ input: 100, output: 50, reasoning: 20, cache: { read: 80, write: 20 } }))).toBe(270) + }) + + test("returns plain numeric tokens as-is", () => { + expect(extractTokensFromMessage(assistantMessage(1234))).toBe(1234) + }) + + test("prefers the reported total when tokens live on a part", () => { + const message: { info: Message; parts: Part[] } = { + info: {} as Message, + parts: [{ tokens: { total: 500, input: 2_000 } } as unknown as Part], + } + expect(extractTokensFromMessage(message)).toBe(500) + }) + + test("returns 0 when neither info nor parts carry tokens", () => { + expect(extractTokensFromMessage({ info: {} as Message, parts: [] })).toBe(0) + }) +}) diff --git a/packages/ui/src/stores/utils/tokenUtils.ts b/packages/ui/src/stores/utils/tokenUtils.ts index 4b944449..0f89ec3b 100644 --- a/packages/ui/src/stores/utils/tokenUtils.ts +++ b/packages/ui/src/stores/utils/tokenUtils.ts @@ -1,6 +1,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2"; type TokenBreakdown = { + /** Server-reported window of the turn's final round-trip. Optional in the schema; absent on older servers. */ + total?: number; input?: number; output?: number; reasoning?: number; @@ -24,6 +26,31 @@ export const sumTokenBreakdown = (breakdown: TokenBreakdown | null | undefined): return inputTokens + outputTokens + reasoningTokens + cacheReadTokens + cacheWriteTokens; }; +/** + * Tokens the context window actually holds, from one message's token payload. + * + * The breakdown fields accumulate across every API round-trip inside a single + * assistant turn: each tool call re-reads the whole (cached) prompt, so on a + * multi-step turn `cache.read` alone can add up to several times the context + * window (observed on opencode 1.18.18: cache.read 3,291,956 on a turn whose + * 1M window really held 232,872 — rendered as a 330% context readout). The + * server reports the final round-trip's window as `tokens.total` (optional in + * the message schema, absent on older servers). Prefer it; fall back to + * summing the fields only when the server did not send it. + */ +export const contextTokensFromBreakdown = (breakdown: TokenBreakdown | null | undefined): number => { + if (!breakdown || typeof breakdown !== 'object') { + return 0; + } + + const reportedTotal = breakdown.total; + if (typeof reportedTotal === 'number' && Number.isFinite(reportedTotal) && reportedTotal > 0) { + return reportedTotal; + } + + return sumTokenBreakdown(breakdown); +}; + export const extractTokensFromMessage = (message: { info: Message; parts: Part[] }): number => { const tokens = (message.info as { tokens?: number | TokenBreakdown }).tokens; @@ -32,7 +59,7 @@ export const extractTokensFromMessage = (message: { info: Message; parts: Part[] } if (tokens && typeof tokens === 'object') { - return sumTokenBreakdown(tokens); + return contextTokensFromBreakdown(tokens); } const tokenPart = message.parts.find( @@ -47,7 +74,7 @@ export const extractTokensFromMessage = (message: { info: Message; parts: Part[] return tokenPart.tokens; } - return sumTokenBreakdown(tokenPart.tokens); + return contextTokensFromBreakdown(tokenPart.tokens); }; type CacheHitRateResult = { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index d6bfd5e3..1d90808c 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -87,6 +87,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch" import { clearLastActiveSession, persistLastActiveSession, readLastActiveSession } from "./last-session-cache" import { persistWorktreeTopology, readPersistedWorktreeTopology } from "./worktree-topology-cache" import { rememberRuntimeLiveStatus } from "./runtime-live-memory" +import { contextTokensFromBreakdown } from "@/stores/utils/tokenUtils" export type { AttachedFile } @@ -1174,7 +1175,7 @@ export const useSessionUIStore = create()((set, get) => ({ const messages = getSyncMessages(sessionId) if (messages.length === 0) return null - type AssistantTokens = { input: number; output: number; reasoning: number; cache: { read: number; write: number } } + type AssistantTokens = { total?: number; input: number; output: number; reasoning: number; cache: { read: number; write: number } } let lastTokens: AssistantTokens | undefined let lastMessageId: string | undefined for (let i = messages.length - 1; i >= 0; i--) { @@ -1182,7 +1183,7 @@ export const useSessionUIStore = create()((set, get) => ({ if (msg.role !== "assistant") continue const tokens = (msg as { tokens?: AssistantTokens }).tokens if (!tokens) continue - const total = tokens.input + tokens.output + tokens.reasoning + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0) + const total = contextTokensFromBreakdown(tokens) if (total > 0) { lastTokens = tokens lastMessageId = msg.id @@ -1192,7 +1193,7 @@ export const useSessionUIStore = create()((set, get) => ({ if (!lastTokens) return null - const totalTokens = lastTokens.input + lastTokens.output + lastTokens.reasoning + (lastTokens.cache?.read ?? 0) + (lastTokens.cache?.write ?? 0) + const totalTokens = contextTokensFromBreakdown(lastTokens) const thresholdLimit = contextLimit > 0 ? contextLimit : 200000 const percentage = contextLimit > 0 ? Math.round((totalTokens / contextLimit) * 100) : 0 const normalizedOutput = outputLimit > 0 ? Math.round((lastTokens.output / outputLimit) * 100) : undefined diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index fc94aca5..d922c4b6 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -4,6 +4,7 @@ - **Settings/Integrations:** a new Integrations settings page lists Claude Code, Command Code, and Cursor plugins with install, update, setup, and remove actions, plus Discord and Telegram Coming soon placeholders. - Chat: new chats no longer start against a deleted last worktree directory; they fall back to the active project instead of saving the first message and never starting. - Chat: opening a busy subagent in the context panel now shows its history instead of only the working-status line (thanks to @makeittech). +- The context usage readout no longer climbs over 100% after turns with many tool calls and no longer jumps when reopening an older session; it now shows what the window actually holds (thanks to @pocharlies). - Projects: project names now match the folder name exactly, so `.ssh` and `opencode-claude` are no longer shown as `.Ssh` and `Opencode Claude`; names you renamed yourself are kept. - Skills Catalog: the source is now named ClawHub instead of "ClawdHub" (thanks to @makeittech).