feat(gsd): show per-prompt token cost in footer behind show_token_cost preference (#2357)

Adds opt-in per-prompt cost display to the interactive footer. Users
enable it by setting `show_token_cost: true` in their preferences.md.
Disabled by default — the footer behavior is unchanged unless opted in.

Fixes #1515

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-03-24 09:18:57 -04:00 committed by GitHub
parent 21f66058ad
commit eb30d3afd4
7 changed files with 171 additions and 0 deletions

View file

@ -255,6 +255,10 @@ export class AgentSession {
private _cumulativeOutputTokens = 0;
private _cumulativeToolCalls = 0;
/** Cost of the most recent assistant response (for per-prompt display). */
private _lastTurnCost = 0;
// Bash execution state
private _bashAbortController: AbortController | undefined = undefined;
private _pendingBashMessages: BashExecutionMessage[] = [];
@ -454,6 +458,7 @@ export class AgentSession {
// Accumulate session stats that survive compaction (#1423)
const assistantMsg = event.message as AssistantMessage;
this._lastTurnCost = assistantMsg.usage?.cost?.total ?? 0;
this._cumulativeCost += assistantMsg.usage?.cost?.total ?? 0;
this._cumulativeInputTokens += assistantMsg.usage?.input ?? 0;
this._cumulativeOutputTokens += assistantMsg.usage?.output ?? 0;
@ -2780,6 +2785,14 @@ export class AgentSession {
};
}
/**
* Get the cost of the most recent assistant response.
* Returns 0 if no assistant message has been received yet.
*/
getLastTurnCost(): number {
return this._lastTurnCost;
}
getContextUsage(): ContextUsage | undefined {
const model = this.model;
if (!model) return undefined;

View file

@ -26,6 +26,18 @@ function formatTokens(count: number): string {
return `${Math.round(count / 1000000)}M`;
}
/**
* Format a cost value for compact display.
* Uses fewer decimal places for larger amounts.
* @internal Exported for testing only.
*/
export function formatPromptCost(cost: number): string {
if (cost < 0.001) return `$${cost.toFixed(4)}`;
if (cost < 0.01) return `$${cost.toFixed(3)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
}
/**
* Footer component that shows pwd, token stats, and context usage.
* Computes token/context stats from session, gets git branch and extension statuses from provider.
@ -112,6 +124,14 @@ export class FooterComponent implements Component {
statsParts.push(costStr);
}
// Per-prompt cost annotation (opt-in via show_token_cost preference, #1515)
if (process.env.GSD_SHOW_TOKEN_COST === "1") {
const lastTurnCost = this.session.getLastTurnCost();
if (lastTurnCost > 0) {
statsParts.push(`(last: ${formatPromptCost(lastTurnCost)})`);
}
}
// Colorize context percentage based on usage
let contextPercentStr: string;
const autoIndicator = this.autoCompactEnabled ? " (auto)" : "";

View file

@ -30,6 +30,13 @@ export function registerHooks(pi: ExtensionAPI): void {
resetWriteGateState();
resetToolCallLoopGuard();
await syncServiceTierStatus(ctx);
// Apply show_token_cost preference (#1515)
try {
const { loadEffectiveGSDPreferences } = await import("../preferences.js");
const prefs = loadEffectiveGSDPreferences();
process.env.GSD_SHOW_TOKEN_COST = prefs?.preferences.show_token_cost ? "1" : "";
} catch { /* non-fatal */ }
if (isFirstSession) {
isFirstSession = false;
} else {

View file

@ -90,6 +90,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"github",
"service_tier",
"forensics_dedup",
"show_token_cost",
]);
/** Canonical list of all dispatch unit types. */
@ -226,6 +227,8 @@ export interface GSDPreferences {
service_tier?: "priority" | "flex";
/** Opt-in: search existing issues and PRs before filing from /gsd forensics. Uses additional AI tokens. */
forensics_dedup?: boolean;
/** Opt-in: show per-prompt and cumulative session token cost in the footer. Default: false. */
show_token_cost?: boolean;
}
export interface LoadedGSDPreferences {

View file

@ -747,5 +747,14 @@ export function validatePreferences(preferences: GSDPreferences): {
}
}
// ─── Show Token Cost ──────────────────────────────────────────────
if (preferences.show_token_cost !== undefined) {
if (typeof preferences.show_token_cost === "boolean") {
validated.show_token_cost = preferences.show_token_cost;
} else {
errors.push("show_token_cost must be a boolean");
}
}
return { preferences: validated, errors, warnings };
}

View file

@ -342,6 +342,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
: undefined,
service_tier: override.service_tier ?? base.service_tier,
forensics_dedup: override.forensics_dedup ?? base.forensics_dedup,
show_token_cost: override.show_token_cost ?? base.show_token_cost,
};
}

View file

@ -0,0 +1,118 @@
/**
* Tests for the show_token_cost preference (#1515).
*
* Covers:
* - Preference recognition and validation
* - Cost formatting accuracy (inline re-implementation for test isolation)
* - Disabled-by-default behavior
* - Preference parsing from markdown frontmatter
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
validatePreferences,
parsePreferencesMarkdown,
} from "../preferences.ts";
import { KNOWN_PREFERENCE_KEYS } from "../preferences-types.ts";
// Re-implement formatPromptCost here for test isolation (avoids pi-coding-agent build dep).
// The canonical implementation lives in footer.ts.
function formatPromptCost(cost: number): string {
if (cost < 0.001) return `$${cost.toFixed(4)}`;
if (cost < 0.01) return `$${cost.toFixed(3)}`;
if (cost < 1) return `$${cost.toFixed(3)}`;
return `$${cost.toFixed(2)}`;
}
// ── Preference recognition ──────────────────────────────────────────────────
test("show_token_cost is a known preference key", () => {
assert.ok(KNOWN_PREFERENCE_KEYS.has("show_token_cost"));
});
test("show_token_cost: true validates without errors", () => {
const { errors, preferences } = validatePreferences({ show_token_cost: true });
assert.equal(errors.length, 0);
assert.equal(preferences.show_token_cost, true);
});
test("show_token_cost: false validates without errors", () => {
const { errors, preferences } = validatePreferences({ show_token_cost: false });
assert.equal(errors.length, 0);
assert.equal(preferences.show_token_cost, false);
});
test("show_token_cost: non-boolean produces validation error", () => {
const { errors } = validatePreferences({ show_token_cost: "yes" as any });
assert.ok(errors.length > 0);
assert.ok(errors[0].includes("show_token_cost"));
assert.ok(errors[0].includes("boolean"));
});
test("show_token_cost does not produce unknown-key warning", () => {
const { warnings } = validatePreferences({ show_token_cost: true });
const unknownWarnings = warnings.filter(w => w.includes("show_token_cost"));
assert.equal(unknownWarnings.length, 0);
});
// ── Disabled by default ─────────────────────────────────────────────────────
test("show_token_cost defaults to undefined (disabled) when not set", () => {
const { preferences } = validatePreferences({});
assert.equal(preferences.show_token_cost, undefined);
});
test("empty preferences.md does not enable show_token_cost", () => {
const prefs = parsePreferencesMarkdown("---\nversion: 1\n---\n");
assert.ok(prefs);
assert.equal(prefs.show_token_cost, undefined);
});
test("preferences.md with show_token_cost: true enables the preference", () => {
const prefs = parsePreferencesMarkdown("---\nshow_token_cost: true\n---\n");
assert.ok(prefs);
assert.equal(prefs.show_token_cost, true);
});
// ── Cost formatting ─────────────────────────────────────────────────────────
test("formatPromptCost formats sub-cent amounts with 4 decimals", () => {
assert.equal(formatPromptCost(0.0003), "$0.0003");
assert.equal(formatPromptCost(0.0009), "$0.0009");
});
test("formatPromptCost formats cent-range amounts with 3 decimals", () => {
assert.equal(formatPromptCost(0.003), "$0.003");
assert.equal(formatPromptCost(0.012), "$0.012");
assert.equal(formatPromptCost(0.1), "$0.100");
});
test("formatPromptCost formats dollar-range amounts with 2 decimals", () => {
assert.equal(formatPromptCost(1.5), "$1.50");
assert.equal(formatPromptCost(12.345), "$12.35");
});
test("formatPromptCost handles zero", () => {
assert.equal(formatPromptCost(0), "$0.0000");
});
// ── Cost calculation correctness ────────────────────────────────────────────
test("cost calculation formula matches Model cost structure", () => {
// Simulates: usage.input * model.cost.input / 1_000_000 + usage.output * model.cost.output / 1_000_000
// Model.cost fields are $/million tokens
const modelCost = { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }; // claude-opus-4 pricing
const usage = { input: 2000, output: 500, cacheRead: 10000, cacheWrite: 1000 };
const cost =
(usage.input * modelCost.input / 1_000_000) +
(usage.output * modelCost.output / 1_000_000) +
(usage.cacheRead * modelCost.cacheRead / 1_000_000) +
(usage.cacheWrite * modelCost.cacheWrite / 1_000_000);
// 2000*15/1M + 500*75/1M + 10000*1.5/1M + 1000*18.75/1M
// = 0.03 + 0.0375 + 0.015 + 0.01875 = 0.10125
assert.ok(Math.abs(cost - 0.10125) < 0.0001, `Expected ~$0.10125 but got $${cost}`);
assert.equal(formatPromptCost(cost), "$0.101");
});