From eb30d3afd482336b507fb6db29844a6a4907c938 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 24 Mar 2026 09:18:57 -0400 Subject: [PATCH] feat(gsd): show per-prompt token cost in footer behind show_token_cost preference (#2357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../pi-coding-agent/src/core/agent-session.ts | 13 ++ .../modes/interactive/components/footer.ts | 20 +++ .../gsd/bootstrap/register-hooks.ts | 7 ++ .../extensions/gsd/preferences-types.ts | 3 + .../extensions/gsd/preferences-validation.ts | 9 ++ src/resources/extensions/gsd/preferences.ts | 1 + .../gsd/tests/token-cost-display.test.ts | 118 ++++++++++++++++++ 7 files changed, 171 insertions(+) create mode 100644 src/resources/extensions/gsd/tests/token-cost-display.test.ts diff --git a/packages/pi-coding-agent/src/core/agent-session.ts b/packages/pi-coding-agent/src/core/agent-session.ts index 4fc8513bf..c300fc20f 100644 --- a/packages/pi-coding-agent/src/core/agent-session.ts +++ b/packages/pi-coding-agent/src/core/agent-session.ts @@ -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; diff --git a/packages/pi-coding-agent/src/modes/interactive/components/footer.ts b/packages/pi-coding-agent/src/modes/interactive/components/footer.ts index 5b4456baa..6a1c49d43 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/footer.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/footer.ts @@ -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)" : ""; diff --git a/src/resources/extensions/gsd/bootstrap/register-hooks.ts b/src/resources/extensions/gsd/bootstrap/register-hooks.ts index 99fa9cc9c..0faa9563f 100644 --- a/src/resources/extensions/gsd/bootstrap/register-hooks.ts +++ b/src/resources/extensions/gsd/bootstrap/register-hooks.ts @@ -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 { diff --git a/src/resources/extensions/gsd/preferences-types.ts b/src/resources/extensions/gsd/preferences-types.ts index c7191c128..b57e2514f 100644 --- a/src/resources/extensions/gsd/preferences-types.ts +++ b/src/resources/extensions/gsd/preferences-types.ts @@ -90,6 +90,7 @@ export const KNOWN_PREFERENCE_KEYS = new Set([ "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 { diff --git a/src/resources/extensions/gsd/preferences-validation.ts b/src/resources/extensions/gsd/preferences-validation.ts index d19468a68..bc9fc17d8 100644 --- a/src/resources/extensions/gsd/preferences-validation.ts +++ b/src/resources/extensions/gsd/preferences-validation.ts @@ -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 }; } diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 85bdc217a..99c91e370 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -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, }; } diff --git a/src/resources/extensions/gsd/tests/token-cost-display.test.ts b/src/resources/extensions/gsd/tests/token-cost-display.test.ts new file mode 100644 index 000000000..e12d9e4db --- /dev/null +++ b/src/resources/extensions/gsd/tests/token-cost-display.test.ts @@ -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"); +});