Skip to content

Commit b12b4a4

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 a33828b commit b12b4a4

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
@@ -1480,7 +1480,11 @@ export function setPromptWorkspace(
14801480
*/
14811481
export function setPromptCostContext(
14821482
shell: AppShell,
1483-
input: { readonly contextPercentUsed: number | null; readonly costLabel?: string | null },
1483+
input: {
1484+
readonly contextPercentUsed: number | null
1485+
readonly costLabel?: string | null
1486+
readonly contextIsEstimate: boolean
1487+
},
14841488
): void {
14851489
const meter = composeCostContextMeter(input)
14861490
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,
@@ -1688,6 +1689,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
16881689
const faremeter = createFaremeter({ modelId: config.model, pricingCache });
16891690
faremeter.addUsage(usage);
16901691
const totalCost = faremeter.getTotalCost();
1692+
// A provider that omits or zeroes usage would otherwise pin the meter at
1693+
// 0% forever; fall back to the director's local estimate (turns plus
1694+
// system-prompt/tool-schema overhead). The governor already decided
1695+
// whether it's estimating when it computed this turn's arming — trust
1696+
// that decision rather than re-deriving it from a second usage read.
1697+
const contextEstimate = directorHolder.instance?.getContextEstimate();
1698+
const isEstimate = contextEstimate !== undefined && contextEstimate.isEstimate;
16911699
return buildCostSummary({
16921700
modelId: config.model,
16931701
baseURL: config.baseURL,
@@ -1697,7 +1705,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
16971705
inputTokens: usage.input,
16981706
outputTokens: usage.output,
16991707
cacheReadTokens: usage.cacheRead,
1700-
contextTokens: lastTurnUsage.input + lastTurnUsage.cacheRead + lastTurnUsage.cacheWrite,
1708+
contextTokens: isEstimate ? contextEstimate.tokens : contextTokensFromUsage(lastTurnUsage),
1709+
contextIsEstimate: isEstimate,
17011710
});
17021711
},
17031712
startWorkflow: (name) => workflowController.start(name),

0 commit comments

Comments
 (0)