Skip to content

Commit 8545ccd

Browse files
committed
Show the context meter as an estimate when it is one
The status bar previously read only the last turn's reported input and cache tokens, with no fallback, so a provider that omits usage pinned the meter at a stale or 0% reading. It now trusts the same estimate the compaction governor already computed, including the governor's own decision on whether that number is estimated, rather than re-deriving that decision from a second usage read. The tilde prefix that marks an estimated percentage is written once and reused by both the status bar and the prompt border. Cost accounting's own input-plus-cache sum is replaced with the same shared function so all three consumers agree on what "context size" means.
1 parent a816d7a commit 8545ccd

10 files changed

Lines changed: 75 additions & 13 deletions

File tree

src/cost/cost-summary.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const baseInput: CostSummaryInput = {
1515
outputTokens: 500,
1616
cacheReadTokens: 200,
1717
contextTokens: 64_000,
18+
contextIsEstimate: false,
1819
};
1920

2021
describe("buildCostSummary", () => {
@@ -81,6 +82,11 @@ describe("formatStatusBarSegments", () => {
8182
expect(segments.contextLabel).toBe("Ctx --%");
8283
expect(segments.contextPercentUsed).toBeNull();
8384
});
85+
86+
it("flags an estimated context percentage with a tilde", () => {
87+
const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true });
88+
expect(formatStatusBarSegments(summary).contextLabel).toBe("Ctx ~50%");
89+
});
8490
});
8591

8692
describe("formatCostCommandOutput", () => {
@@ -116,4 +122,9 @@ describe("formatCostCommandOutput", () => {
116122
const summary = buildCostSummary(baseInput);
117123
expect(formatCostCommandOutput(summary)).toContain("Context: 64000/unknown (--%)");
118124
});
125+
126+
it("flags an estimated context percentage with a tilde", () => {
127+
const summary = buildCostSummary({ ...baseInput, contextIsEstimate: true });
128+
expect(formatCostCommandOutput(summary)).toContain("(~50%)");
129+
});
119130
});

src/cost/cost-summary.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ export type CostSummaryInput = {
1717
outputTokens: number;
1818
cacheReadTokens: number;
1919
contextTokens: number;
20+
// True when contextTokens came from the local character-count estimate
21+
// because the provider omitted or zeroed usage on the latest turn, rather
22+
// than from provider-reported usage. Lets the display flag the number as
23+
// approximate instead of implying provider-grade precision. The caller
24+
// building this input owns the decision; nothing downstream re-derives it.
25+
contextIsEstimate: boolean;
2026
};
2127

2228
export type CostSummary = CostSummaryInput & {
@@ -53,8 +59,13 @@ export type StatusBarCostSegments = {
5359
contextPercentUsed: number | null;
5460
};
5561

56-
function formatContextPercent(percent: number | null): string {
57-
return percent === null ? "--%" : `${String(percent)}%`;
62+
// "~" flags a locally estimated number so the operator doesn't read it as
63+
// provider-confirmed. The one place this rule is encoded; every renderer of
64+
// a context percentage (status bar, prompt border, /cost output) calls this
65+
// rather than re-deciding the prefix itself.
66+
export function formatContextPercentLabel(percent: number | null, isEstimate: boolean): string {
67+
if (percent === null) return "--%";
68+
return `${isEstimate ? "~" : ""}${String(percent)}%`;
5869
}
5970

6071
// Status bar space is tight, so cost is omitted entirely (not shown as $0 or
@@ -64,7 +75,7 @@ function formatContextPercent(percent: number | null): string {
6475
export function formatStatusBarSegments(summary: CostSummary): StatusBarCostSegments {
6576
return {
6677
...(summary.costHiddenReason === null ? { costLabel: summary.formattedCost } : {}),
67-
contextLabel: `Ctx ${formatContextPercent(summary.contextPercentUsed)}`,
78+
contextLabel: `Ctx ${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)}`,
6879
contextPercentUsed: summary.contextPercentUsed,
6980
};
7081
}
@@ -84,7 +95,7 @@ export function formatCostCommandOutput(summary: CostSummary): string {
8495
? `Cost: ${summary.formattedCost}`
8596
: `Cost: hidden (${HIDDEN_REASON_TEXT[summary.costHiddenReason]})`,
8697
`Tokens: ${String(summary.inputTokens)} in / ${String(summary.outputTokens)} out / ${String(summary.cacheReadTokens)} cache-read`,
87-
`Context: ${String(summary.contextTokens)}/${window} (${formatContextPercent(summary.contextPercentUsed)})`,
98+
`Context: ${String(summary.contextTokens)}/${window} (${formatContextPercentLabel(summary.contextPercentUsed, summary.contextIsEstimate)})`,
8899
];
89100
return lines.join("\n");
90101
}

src/cost/faremeter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { TokenUsage } from "@intx/types/runtime";
22

33
import { lookupModelPricing, type ModelPricing, type PricingCache } from "./pricing-fetcher.js";
4+
import { contextTokensFromUsage } from "../provider/context-window.js";
45

56
export type FaremeterConfig = {
67
inputPricePerToken: number;
@@ -60,7 +61,7 @@ export function createFaremeter(config: CreateFaremeterConfig = {}): Faremeter {
6061
return {
6162
addUsage(usage: TokenUsage): void {
6263
const { inputPricePerToken, outputPricePerToken, cacheReadPricePerToken } = pricesFor();
63-
lastContextSize = usage.input + usage.cacheRead + usage.cacheWrite;
64+
lastContextSize = contextTokensFromUsage(usage);
6465
outputTokens += usage.output + usage.thinking;
6566
totalCost += usage.input * inputPricePerToken + usage.output * outputPricePerToken + usage.cacheRead * cacheReadPricePerToken;
6667
},

src/tui-opentui/prompt-border.test.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,30 +155,49 @@ describe("composeRule", () => {
155155

156156
describe("composeCostContextMeter", () => {
157157
test("null when the context window is unknown", () => {
158-
expect(composeCostContextMeter({ contextPercentUsed: null })).toBeNull()
158+
expect(composeCostContextMeter({ contextPercentUsed: null, contextIsEstimate: false })).toBeNull()
159159
})
160160

161161
test("carries the percent and cost", () => {
162-
const meter = composeCostContextMeter({ contextPercentUsed: 68, costLabel: "$0.42" })
162+
const meter = composeCostContextMeter({
163+
contextPercentUsed: 68,
164+
costLabel: "$0.42",
165+
contextIsEstimate: false,
166+
})
163167
expect(meter).not.toBeNull()
164168
expect(meter!.percentLabel).toBe("68%")
165169
expect(meter!.costLabel).toBe("$0.42")
166170
})
167171

168172
test("drops the cost suffix when told to, keeping the percent", () => {
169-
const meter = composeCostContextMeter({ contextPercentUsed: 68, costLabel: "$0.42" })!
173+
const meter = composeCostContextMeter({
174+
contextPercentUsed: 68,
175+
costLabel: "$0.42",
176+
contextIsEstimate: false,
177+
})!
170178
expect(costContextText(meter, true)).toContain("$0.42")
171179
expect(costContextText(meter, false)).not.toContain("$0.42")
172180
expect(costContextText(meter, false)).toContain("68%")
173181
})
174182

175183
test("turns pressured past the threshold, not before it", () => {
176184
const thresholdPercent = CONTEXT_PRESSURE_THRESHOLD * 100
177-
const below = composeCostContextMeter({ contextPercentUsed: thresholdPercent - 1 })!
178-
const atOrAbove = composeCostContextMeter({ contextPercentUsed: thresholdPercent })!
185+
const below = composeCostContextMeter({
186+
contextPercentUsed: thresholdPercent - 1,
187+
contextIsEstimate: false,
188+
})!
189+
const atOrAbove = composeCostContextMeter({
190+
contextPercentUsed: thresholdPercent,
191+
contextIsEstimate: false,
192+
})!
179193
expect(below.pressured).toBe(false)
180194
expect(atOrAbove.pressured).toBe(true)
181195
})
196+
197+
test("flags an estimated percent with a tilde", () => {
198+
const meter = composeCostContextMeter({ contextPercentUsed: 68, contextIsEstimate: true })!
199+
expect(meter.percentLabel).toBe("~68%")
200+
})
182201
})
183202

184203
describe("abbreviateHome", () => {

src/tui-opentui/prompt-border.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import { stringWidth } from "../tui/view/height.js"
1616
import { renderRamp } from "./ramp.js"
17+
import { formatContextPercentLabel } from "../cost/cost-summary.js"
1718

1819
/** Rounded box drawing, all single-cell. */
1920
export const BORDER = {
@@ -229,6 +230,9 @@ export type CostContextInput = {
229230
readonly contextPercentUsed: number | null
230231
/** Already formatted (e.g. `$0.42`); omitted or empty hides the cost suffix. */
231232
readonly costLabel?: string | null
233+
/** True when `contextPercentUsed` came from the local estimate because the
234+
* provider omitted or zeroed usage, rather than from reported usage. */
235+
readonly contextIsEstimate: boolean
232236
}
233237

234238
export type CostContextMeter = {
@@ -249,7 +253,7 @@ export function composeCostContextMeter(input: CostContextInput): CostContextMet
249253
const percent = Math.max(0, Math.min(100, Math.round(input.contextPercentUsed)))
250254
const cost = input.costLabel?.trim() ?? ""
251255
return {
252-
percentLabel: `${String(percent)}%`,
256+
percentLabel: formatContextPercentLabel(percent, input.contextIsEstimate),
253257
costLabel: cost.length > 0 ? cost : null,
254258
pressured: percent / 100 >= CONTEXT_PRESSURE_THRESHOLD,
255259
}

src/tui-opentui/runner-host.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function fakeCostSummary(): CostSummary {
3131
outputTokens: 50,
3232
cacheReadTokens: 0,
3333
contextTokens: 1000,
34+
contextIsEstimate: false,
3435
costHiddenReason: null,
3536
contextWindow: 10000,
3637
contextPercentUsed: 10,

src/tui-opentui/runner-host.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
257257
setPromptCostContext(host.shell, {
258258
contextPercentUsed: summary.contextPercentUsed,
259259
costLabel: showCost && summary.costHiddenReason === null ? summary.formattedCost : null,
260+
contextIsEstimate: summary.contextIsEstimate,
260261
})
261262
}
262263
pushCostContext()

src/tui-opentui/shell.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1519,7 +1519,11 @@ export function setPromptWorkspace(
15191519
*/
15201520
export function setPromptCostContext(
15211521
shell: AppShell,
1522-
input: { readonly contextPercentUsed: number | null; readonly costLabel?: string | null },
1522+
input: {
1523+
readonly contextPercentUsed: number | null
1524+
readonly costLabel?: string | null
1525+
readonly contextIsEstimate: boolean
1526+
},
15231527
): void {
15241528
const meter = composeCostContextMeter(input)
15251529
if (meterEquals(meter, shell.costContext)) return

src/tui/commands/built-in.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ describe("/cost command", () => {
148148
outputTokens: 50,
149149
cacheReadTokens: 10,
150150
contextTokens: 160,
151+
contextIsEstimate: false,
151152
});
152153
const result = getCommand("cost")!.handler("", ctx);
153154
expect(result.type).toBe("message");

src/tui/runner.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import { defaultPricingCachePath } from "../cost/pricing-fetcher.js";
9494
import { getActivePricingCache } from "../cost/cost-visibility.js";
9595
import { createFaremeter, formatCost } from "../cost/faremeter.js";
9696
import { buildCostSummary, type CostSummary } from "../cost/cost-summary.js";
97+
import { contextTokensFromUsage } from "../provider/context-window.js";
9798
import {
9899
advertisedToolNamesForSessionMode,
99100
advertisedTools,
@@ -1724,6 +1725,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
17241725
const faremeter = createFaremeter({ modelId: config.model, pricingCache });
17251726
faremeter.addUsage(usage);
17261727
const totalCost = faremeter.getTotalCost();
1728+
// A provider that omits or zeroes usage would otherwise pin the meter at
1729+
// 0% forever; fall back to the director's local estimate (turns plus
1730+
// system-prompt/tool-schema overhead). The governor already decided
1731+
// whether it's estimating when it computed this turn's arming — trust
1732+
// that decision rather than re-deriving it from a second usage read.
1733+
const contextEstimate = directorHolder.instance?.getContextEstimate();
1734+
const isEstimate = contextEstimate !== undefined && contextEstimate.isEstimate;
17271735
return buildCostSummary({
17281736
modelId: config.model,
17291737
baseURL: config.baseURL,
@@ -1733,7 +1741,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
17331741
inputTokens: usage.input,
17341742
outputTokens: usage.output,
17351743
cacheReadTokens: usage.cacheRead,
1736-
contextTokens: lastTurnUsage.input + lastTurnUsage.cacheRead + lastTurnUsage.cacheWrite,
1744+
contextTokens: isEstimate ? contextEstimate.tokens : contextTokensFromUsage(lastTurnUsage),
1745+
contextIsEstimate: isEstimate,
17371746
});
17381747
},
17391748
startWorkflow: (name) => workflowController.start(name),

0 commit comments

Comments
 (0)