From e7738832c219688734ba3c8538291dd7be21c98c Mon Sep 17 00:00:00 2001 From: Leo Kim Date: Wed, 13 May 2026 16:58:59 +0900 Subject: [PATCH] fix(coding-agent): align status-line context% with /context command output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Status-line's context_pct segment was computing tokens via calculatePromptTokens(lastAssistantMessage.usage), which sums input + cacheRead + cacheWrite from the Anthropic API usage object. The /context slash command is computed by computeContextBreakdown, an offline estimate over the live session state (systemPrompt + tools + skills + messages). Both numbers are correct under their own definition, but they can diverge by 2x+ on the same session when a turn rotates cache tiers (e.g. 5m → 1h ephemeral re-cache) and cache_creation_input_tokens spikes. Users read the two surfaces as one consistent dashboard and treat the mismatch as a bug. Repro: same session at the same moment reports 212K (21.2%) in /context and 44.2%/1M in the status line — ~230K gap driven by per-turn cache_creation on a system-prompt boundary. This change makes status-line use the same computeContextBreakdown source as /context so both surfaces stay consistent. The breakdown result is cached with a 2s TTL inside the component so the per-frame status-line render does not re-walk every message via estimateMessagesTokens on long sessions. The Anthropic API per-turn prompt size remains observable via existing token_in / cache_read / cache_write / token_total segments. --- .../src/modes/components/status-line.ts | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/modes/components/status-line.ts b/packages/coding-agent/src/modes/components/status-line.ts index 3d8aab6ac..7b94c2507 100644 --- a/packages/coding-agent/src/modes/components/status-line.ts +++ b/packages/coding-agent/src/modes/components/status-line.ts @@ -1,5 +1,4 @@ import * as fs from "node:fs"; -import type { AssistantMessage } from "@oh-my-pi/pi-ai"; import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; import { formatCount, getProjectDir } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; @@ -7,7 +6,7 @@ import { settings } from "../../config/settings"; import type { StatusLinePreset, StatusLineSegmentId, StatusLineSeparatorStyle } from "../../config/settings-schema"; import { theme } from "../../modes/theme/theme"; import type { AgentSession } from "../../session/agent-session"; -import { calculatePromptTokens } from "../../session/compaction/compaction"; +import { computeContextBreakdown } from "../utils/context-usage"; import * as git from "../../utils/git"; import { getSessionAccentAnsi, getSessionAccentHex } from "../../utils/session-color"; import { sanitizeStatusText } from "../shared"; @@ -73,6 +72,10 @@ export class StatusLineComponent implements Component { #lastTokensPerSecond: number | null = null; #lastTokensPerSecondTimestamp: number | null = null; + // Context breakdown caching (2s TTL — aligns with /context command output) + #cachedBreakdown: { usedTokens: number; contextWindow: number } | null = null; + #breakdownFetchedAt = 0; + constructor(private readonly session: AgentSession) { this.#settings = { preset: settings.get("statusLine.preset"), @@ -301,6 +304,19 @@ export class StatusLineComponent implements Component { return null; } + #getCachedContextBreakdown(): { usedTokens: number; contextWindow: number } { + const now = Date.now(); + if (!this.#cachedBreakdown || now - this.#breakdownFetchedAt > 2_000) { + const breakdown = computeContextBreakdown(this.session); + this.#cachedBreakdown = { + usedTokens: breakdown.usedTokens, + contextWindow: breakdown.contextWindow, + }; + this.#breakdownFetchedAt = now; + } + return this.#cachedBreakdown; + } + #buildSegmentContext(width: number): SegmentContext { const state = this.session.state; @@ -318,14 +334,10 @@ export class StatusLineComponent implements Component { tokensPerSecond: this.#getTokensPerSecond(), }; - // Get context percentage - const lastAssistantMessage = state.messages - .slice() - .reverse() - .find(m => m.role === "assistant" && m.stopReason !== "aborted") as AssistantMessage | undefined; - - const contextTokens = lastAssistantMessage ? calculatePromptTokens(lastAssistantMessage.usage) : 0; - const contextWindow = state.model?.contextWindow || 0; + // Context usage — aligned with /context command so both surfaces report the same value + const breakdown = this.#getCachedContextBreakdown(); + const contextTokens = breakdown.usedTokens; + const contextWindow = breakdown.contextWindow || state.model?.contextWindow || 0; const contextPercent = contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0; return {