diff --git a/src/agent-manager.ts b/src/agent-manager.ts
index bfc3cc93..0f3c0c97 100644
--- a/src/agent-manager.ts
+++ b/src/agent-manager.ts
@@ -106,7 +106,7 @@ interface SpawnOptions {
/** Called at the end of each agentic turn with the cumulative count. */
onTurnEnd?: (turnCount: number) => void;
/** Called once per assistant message_end with that message's usage delta. */
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number; cost?: number }) => void;
/** Called when the session successfully compacts. */
onCompaction?: (info: CompactionInfo) => void;
/** Nesting depth: top-level subagent = 1. */
diff --git a/src/agent-runner.ts b/src/agent-runner.ts
index 362e9a8c..b21aa846 100644
--- a/src/agent-runner.ts
+++ b/src/agent-runner.ts
@@ -396,7 +396,7 @@ export interface RunOptions {
* Lets callers maintain a lifetime accumulator that survives compaction
* (which replaces session.state.messages and resets stats-derived sums).
*/
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number; cost?: number }) => void;
/**
* Called when the session successfully compacts. `tokensBefore` is upstream's
* pre-compaction context size estimate. Aborted compactions don't fire.
@@ -935,11 +935,15 @@ export async function runAgent(
}
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
- if (u) options.onAssistantUsage?.({
- input: u.input ?? 0,
- output: u.output ?? 0,
- cacheWrite: u.cacheWrite ?? 0,
- });
+ if (u) {
+ const cost = typeof u.cost?.total === "number" && Number.isFinite(u.cost.total) ? u.cost.total : undefined;
+ options.onAssistantUsage?.({
+ input: u.input ?? 0,
+ output: u.output ?? 0,
+ cacheWrite: u.cacheWrite ?? 0,
+ ...(cost !== undefined ? { cost } : {}),
+ });
+ }
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
@@ -981,7 +985,7 @@ export async function resumeAgent(
prompt: string,
options: {
onToolActivity?: (activity: ToolActivity) => void;
- onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
+ onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number; cost?: number }) => void;
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
signal?: AbortSignal;
} = {},
@@ -999,11 +1003,15 @@ export async function resumeAgent(
if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
- if (u) options.onAssistantUsage?.({
- input: u.input ?? 0,
- output: u.output ?? 0,
- cacheWrite: u.cacheWrite ?? 0,
- });
+ if (u) {
+ const cost = typeof u.cost?.total === "number" && Number.isFinite(u.cost.total) ? u.cost.total : undefined;
+ options.onAssistantUsage?.({
+ input: u.input ?? 0,
+ output: u.output ?? 0,
+ cacheWrite: u.cacheWrite ?? 0,
+ ...(cost !== undefined ? { cost } : {}),
+ });
+ }
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
diff --git a/src/index.ts b/src/index.ts
index 8736dd23..0ca9883b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -32,7 +32,7 @@ import { SubagentScheduler } from "./schedule.js";
import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged, type ToolDescriptionMode } from "./settings.js";
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
-import { type AgentConfig, type AgentInvocation, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType, type WidgetMode } from "./types.js";
+import { type AgentConfig, type AgentInvocation, type AgentRecord, type GroupSummary, type JoinMode, type NotificationDetails, type SubagentType, type WidgetMode } from "./types.js";
import {
type AgentActivity,
type AgentDetails,
@@ -52,7 +52,7 @@ import {
} from "./ui/agent-widget.js";
import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
import { showSchedulesMenu } from "./ui/schedule-menu.js";
-import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
+import { addUsage, formatCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
// ---- Shared helpers ----
@@ -79,6 +79,23 @@ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string {
return t > 0 ? formatTokens(t) : "";
}
+/** Format an agent's reported model cost, or "" when unavailable. */
+function formatLifetimeCost(o: { lifetimeUsage: LifetimeUsage }): string {
+ return formatCost(o.lifetimeUsage.cost);
+}
+
+/** Format aggregate stats for a grouped notification. */
+export function formatGroupSummary(summary: GroupSummary, includeCost: boolean): string {
+ const countLabel = `${summary.count} agent${summary.count === 1 ? "" : "s"} completed${summary.partial ? " (partial)" : ""}`;
+ const parts = [countLabel];
+ if (summary.totalTokens > 0) parts.push(formatTokens(summary.totalTokens));
+ if (includeCost) {
+ const cost = formatCost(summary.totalCost);
+ if (cost) parts.push(cost);
+ }
+ return parts.join(" · ");
+}
+
/**
* Create an AgentActivity state and spawn callbacks for tracking tool usage.
* Used by both foreground and background paths to avoid duplication.
@@ -117,7 +134,7 @@ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) {
onSessionCreated: (session: any) => {
state.session = session;
},
- onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => {
+ onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number; cost?: number }) => {
addUsage(state.lifetimeUsage, usage);
onStreamUpdate?.();
},
@@ -152,7 +169,7 @@ function escapeXml(s: string): string {
}
/** Format a structured task notification matching Claude Code's XML. */
-function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string {
+function formatTaskNotification(record: AgentRecord, resultMaxLen: number, includeCost = false): string {
const status = getStatusLabel(record.status, record.error);
const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0;
const totalTokens = getLifetimeTotal(record.lifetimeUsage);
@@ -174,7 +191,7 @@ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): stri
`${escapeXml(status)}`,
`Agent "${escapeXml(record.description)}" ${record.status}${getStatusNote(record.status)}`,
`${escapeXml(resultPreview)}`,
- `${totalTokens}${record.toolUses}${ctxXml}${compactXml}${durationMs}`,
+ `${totalTokens}${record.toolUses}${ctxXml}${compactXml}${includeCost && record.lifetimeUsage.cost !== undefined ? `${record.lifetimeUsage.cost}` : ""}${durationMs}`,
``,
].filter(Boolean).join('\n');
}
@@ -190,6 +207,7 @@ function buildDetails(
...base,
toolUses: record.toolUses,
tokens: formatLifetimeTokens(record),
+ cost: record.lifetimeUsage.cost,
turnCount: activity?.turnCount,
maxTurns: activity?.maxTurns,
durationMs: (record.completedAt ?? Date.now()) - record.startedAt,
@@ -212,6 +230,7 @@ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, act
turnCount: activity?.turnCount ?? 0,
maxTurns: activity?.maxTurns,
totalTokens,
+ cost: record.lifetimeUsage.cost,
durationMs: record.completedAt ? record.completedAt - record.startedAt : 0,
outputFile: record.outputFile,
error: record.error,
@@ -229,6 +248,11 @@ export default function (pi: ExtensionAPI) {
// injected as scoped custom tools by the existing manager instead.
if (inChildSessionContext()) return;
+ let costDisplayEnabled = false;
+ function isCostDisplayEnabled(): boolean { return costDisplayEnabled; }
+ let groupSummaryEnabled = false;
+ function isGroupSummaryEnabled(): boolean { return groupSummaryEnabled; }
+
// ---- Register custom notification renderer ----
pi.registerMessageRenderer(
"subagent-notification",
@@ -251,6 +275,7 @@ export default function (pi: ExtensionAPI) {
if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns));
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens));
+ if (isCostDisplayEnabled() && d.cost !== undefined) parts.push(formatCost(d.cost));
if (d.durationMs > 0) parts.push(formatMs(d.durationMs));
if (parts.length) {
line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " ");
@@ -274,7 +299,12 @@ export default function (pi: ExtensionAPI) {
}
const all = [d, ...(d.others ?? [])];
- return new Text(all.map(renderOne).join("\n"), 0, 0);
+ const summary = isGroupSummaryEnabled() && d.groupSummary
+ ? formatGroupSummary(d.groupSummary, isCostDisplayEnabled())
+ : "";
+ const rendered = all.map(renderOne);
+ if (summary) rendered.unshift(theme.fg("dim", summary));
+ return new Text(rendered.join("\n"), 0, 0);
}
);
@@ -319,7 +349,7 @@ export default function (pi: ExtensionAPI) {
function emitIndividualNudge(record: AgentRecord) {
if (record.resultConsumed) return; // re-check at send time
- const notification = formatTaskNotification(record, 500);
+ const notification = formatTaskNotification(record, 500, isCostDisplayEnabled());
const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : '';
pi.sendMessage({
@@ -349,13 +379,22 @@ export default function (pi: ExtensionAPI) {
const unconsumed = records.filter(r => !r.resultConsumed);
if (unconsumed.length === 0) { widget.update(); return; }
- const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
+ const notifications = unconsumed.map(r => formatTaskNotification(r, 300, isCostDisplayEnabled())).join('\n\n');
const label = partial
? `${unconsumed.length} agent(s) finished (partial — others still running)`
: `${unconsumed.length} agent(s) finished`;
const [first, ...rest] = unconsumed;
+ const costs = unconsumed
+ .map((record) => record.lifetimeUsage.cost)
+ .filter((cost): cost is number => cost !== undefined && Number.isFinite(cost));
const details = buildNotificationDetails(first, 300, agentActivity.get(first.id));
+ details.groupSummary = {
+ count: unconsumed.length,
+ partial,
+ totalTokens: unconsumed.reduce((total, record) => total + getLifetimeTotal(record.lifetimeUsage), 0),
+ totalCost: costs.length === unconsumed.length ? costs.reduce((total, cost) => total + cost, 0) : undefined,
+ };
if (rest.length > 0) {
details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id)));
}
@@ -602,11 +641,13 @@ export default function (pi: ExtensionAPI) {
// everything else; "off" = hide the widget entirely. Read live at render time.
let widgetMode: WidgetMode = "background";
function getWidgetMode(): WidgetMode { return widgetMode; }
- const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
+ const widget = new AgentWidget(manager, agentActivity, getWidgetMode, isCostDisplayEnabled);
function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
+ function setCostDisplayEnabled(b: boolean): void { costDisplayEnabled = b; widget.update(); }
+ function setGroupSummaryEnabled(b: boolean): void { groupSummaryEnabled = b; }
// Claude Code-style FleetView: navigable list of main + subagents below the editor.
- const fleet = new FleetList(manager, agentActivity);
+ const fleet = new FleetList(manager, agentActivity, isCostDisplayEnabled);
let fleetViewEnabled = true;
function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
@@ -762,6 +803,8 @@ export default function (pi: ExtensionAPI) {
setOutputTranscript: setOutputTranscriptDefault,
setMaxSubagentDepth: setMaxSubagentDepth,
setFallbackSubagent: setFallbackSubagent,
+ setShowCost: setCostDisplayEnabled,
+ setShowGroupSummary: setGroupSummaryEnabled,
},
(event, payload) => pi.events.emit(event, payload),
);
@@ -986,6 +1029,7 @@ Terse command-style prompts produce shallow, generic work.
}
if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`);
if (d.tokens) parts.push(d.tokens);
+ if (isCostDisplayEnabled() && d.cost !== undefined) parts.push(formatCost(d.cost));
return parts.map(p => fgPreservingNestedStyles(theme, "dim", p)).join(" " + theme.fg("dim", "·") + " ");
};
@@ -1325,6 +1369,7 @@ Terse command-style prompts produce shallow, generic work.
...detailBase,
toolUses: fgState.toolUses,
tokens: formatLifetimeTokens(fgState),
+ cost: fgState.lifetimeUsage.cost,
turnCount: fgState.turnCount,
maxTurns: fgState.maxTurns,
durationMs: Date.now() - startedAt,
@@ -1421,6 +1466,10 @@ Terse command-style prompts produce shallow, generic work.
const durationMs = (record.completedAt ?? Date.now()) - record.startedAt;
const statsParts = [`${record.toolUses} tool uses`];
if (tokenText) statsParts.push(tokenText);
+ if (isCostDisplayEnabled()) {
+ const costText = formatLifetimeCost(record);
+ if (costText) statsParts.push(costText);
+ }
return textResult(
`${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getForegroundOutcomeNote(record.status)}.\n\n` +
(record.result?.trim() || "No output."),
@@ -1479,6 +1528,10 @@ Terse command-style prompts produce shallow, generic work.
const contextPercent = getSessionContextPercent(record.session);
const statsParts = [`Tool uses: ${record.toolUses}`];
if (tokens) statsParts.push(tokens);
+ if (isCostDisplayEnabled()) {
+ const costText = formatLifetimeCost(record);
+ if (costText) statsParts.push(`Cost: ${costText}`);
+ }
if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`);
if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`);
statsParts.push(`Duration: ${duration}`);
@@ -1554,6 +1607,10 @@ Terse command-style prompts produce shallow, generic work.
const contextPercent = getSessionContextPercent(record.session);
const stateParts: string[] = [];
if (tokens) stateParts.push(tokens);
+ if (isCostDisplayEnabled()) {
+ const costText = formatLifetimeCost(record);
+ if (costText) stateParts.push(costText);
+ }
stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`);
if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`);
if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`);
@@ -1772,7 +1829,7 @@ Terse command-style prompts produce shallow, generic work.
if (manager.abort(record.id)) {
ctx.ui.notify(`Stopped "${record.description}".`, "info");
}
- }, keybindings, (message: string) => manager.steer(record.id, message));
+ }, keybindings, (message: string) => manager.steer(record.id, message), isCostDisplayEnabled);
},
{
overlay: true,
@@ -2152,6 +2209,8 @@ ${systemPrompt}
// explicit configuration — which then fails loudly if general-purpose later
// goes away. undefined is dropped by JSON.stringify.
fallbackSubagent: getFallbackSubagent(),
+ showCost: isCostDisplayEnabled(),
+ showGroupSummary: isGroupSummaryEnabled(),
};
}
@@ -2242,6 +2301,20 @@ ${systemPrompt}
currentValue: getOutputTranscriptDefault() ? "on" : "off",
values: ["on", "off"],
},
+ {
+ id: "showCost",
+ label: "Cost display",
+ description: "Show reported model cost in agent progress and results",
+ currentValue: isCostDisplayEnabled() ? "on" : "off",
+ values: ["on", "off"],
+ },
+ {
+ id: "showGroupSummary",
+ label: "Group summary",
+ description: "Show aggregate stats for grouped background-agent notifications",
+ currentValue: isGroupSummaryEnabled() ? "on" : "off",
+ values: ["on", "off"],
+ },
{
id: "fleetView",
label: "Fleet view",
@@ -2334,6 +2407,14 @@ ${systemPrompt}
const enabled = value === "on";
setOutputTranscriptDefault(enabled);
notifyApplied(ctx, `Output transcript ${enabled ? "enabled" : "disabled"} by default`);
+ } else if (id === "showCost") {
+ const enabled = value === "on";
+ setCostDisplayEnabled(enabled);
+ notifyApplied(ctx, `Cost display ${enabled ? "enabled" : "disabled"}`);
+ } else if (id === "showGroupSummary") {
+ const enabled = value === "on";
+ setGroupSummaryEnabled(enabled);
+ notifyApplied(ctx, `Group summary ${enabled ? "enabled" : "disabled"}`);
} else if (id === "toolDescriptionMode") {
setToolDescriptionMode(value as ToolDescriptionMode);
notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`);
diff --git a/src/settings.ts b/src/settings.ts
index ac6299dd..4aa77310 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -117,6 +117,10 @@ export interface SubagentsSettings {
* meaning one thing here and another in the resolver.
*/
fallbackSubagent?: string;
+ /** Whether to show reported model costs in subagent UI and results. Defaults to false. */
+ showCost?: boolean;
+ /** Whether to show aggregate stats for grouped background-agent notifications. Defaults to false. */
+ showGroupSummary?: boolean;
}
export type ToolDescriptionMode = "full" | "compact" | "custom";
@@ -136,6 +140,8 @@ export interface SettingsAppliers {
setOutputTranscript: (b: boolean) => void;
setMaxSubagentDepth: (n: number) => void;
setFallbackSubagent: (v: string | undefined) => void;
+ setShowCost: (b: boolean) => void;
+ setShowGroupSummary: (b: boolean) => void;
}
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
@@ -220,6 +226,12 @@ function sanitize(raw: unknown): SubagentsSettings {
} else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
out.fallbackSubagent = r.fallbackSubagent.trim();
}
+ if (typeof r.showCost === "boolean") {
+ out.showCost = r.showCost;
+ }
+ if (typeof r.showGroupSummary === "boolean") {
+ out.showGroupSummary = r.showGroupSummary;
+ }
return out;
}
@@ -283,6 +295,8 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers):
if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView);
if (s.widgetMode) appliers.setWidgetMode(s.widgetMode);
if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript);
+ if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost);
+ if (typeof s.showGroupSummary === "boolean") appliers.setShowGroupSummary(s.showGroupSummary);
}
/**
diff --git a/src/types.ts b/src/types.ts
index 3d566aa4..0ea9d799 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -115,7 +115,8 @@ export interface AgentRecord {
/**
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
- * excluded — see issue #38). Initialized to zeros at spawn.
+ * excluded — see issue #38). Reported model cost is accumulated separately
+ * when available. Initialized to zeros at spawn.
*/
lifetimeUsage: LifetimeUsage;
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
@@ -158,6 +159,15 @@ export interface AgentInvocation {
isolation?: IsolationMode;
}
+/** Aggregate stats attached to a grouped notification. */
+export interface GroupSummary {
+ count: number;
+ partial: boolean;
+ totalTokens: number;
+ /** Sum of reported model costs, when available for every member. */
+ totalCost?: number;
+}
+
/** Details attached to custom notification messages for visual rendering. */
export interface NotificationDetails {
id: string;
@@ -171,6 +181,10 @@ export interface NotificationDetails {
outputFile?: string;
error?: string;
resultPreview: string;
+ /** Reported model cost, when available. */
+ cost?: number;
+ /** Aggregate stats for a grouped notification. */
+ groupSummary?: GroupSummary;
/** Additional agents in a group notification. */
others?: NotificationDetails[];
}
diff --git a/src/ui/agent-widget.ts b/src/ui/agent-widget.ts
index 3cbe9cc8..90195957 100644
--- a/src/ui/agent-widget.ts
+++ b/src/ui/agent-widget.ts
@@ -9,7 +9,7 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
import type { AgentManager } from "../agent-manager.js";
import { getConfig } from "../agent-types.js";
import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
-import { getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
+import { formatCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
// ---- Constants ----
@@ -84,6 +84,8 @@ export interface AgentDetails {
turnCount?: number;
/** Effective max turns (undefined = unlimited). */
maxTurns?: number;
+ /** Reported model cost, when available. */
+ cost?: number;
agentId?: string;
error?: string;
}
@@ -237,6 +239,7 @@ export class AgentWidget {
* extension supplies one defaulting to `"background"`.
*/
private mode: () => WidgetMode = () => "all",
+ private showCost: () => boolean = () => false,
) {}
/**
@@ -306,7 +309,7 @@ export class AgentWidget {
}
/** Render a finished agent line. */
- private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
+ private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage: LifetimeUsage }, theme: Theme): string {
const name = getDisplayName(a.type);
const modeLabel = getPromptModeLabel(a.type);
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
@@ -336,6 +339,11 @@ export class AgentWidget {
const activity = this.agentActivity.get(a.id);
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
+ const totalTokens = getLifetimeTotal(a.lifetimeUsage);
+ if (totalTokens > 0) parts.push(formatTokens(totalTokens));
+ if (this.showCost() && a.lifetimeUsage.cost !== undefined) {
+ parts.push(formatCost(a.lifetimeUsage.cost));
+ }
parts.push(duration);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
@@ -384,7 +392,8 @@ export class AgentWidget {
const bg = this.agentActivity.get(a.id);
const toolUses = bg?.toolUses ?? a.toolUses;
- const tokens = getLifetimeTotal(bg?.lifetimeUsage);
+ const usage = bg?.lifetimeUsage ?? a.lifetimeUsage;
+ const tokens = getLifetimeTotal(usage);
const contextPercent = getSessionContextPercent(bg?.session);
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
@@ -392,6 +401,7 @@ export class AgentWidget {
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
if (tokenText) parts.push(tokenText);
+ if (this.showCost() && usage.cost !== undefined) parts.push(formatCost(usage.cost));
parts.push(elapsed);
const statsText = parts.join(" · ");
diff --git a/src/ui/conversation-viewer.ts b/src/ui/conversation-viewer.ts
index e482344e..12a69f09 100644
--- a/src/ui/conversation-viewer.ts
+++ b/src/ui/conversation-viewer.ts
@@ -9,7 +9,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
-import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
+import { formatCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
import type { Theme } from "./agent-widget.js";
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
@@ -45,6 +45,8 @@ export class ConversationViewer implements Component {
keybindings?: ViewerKeybindings,
/** Send a steering message to the agent. Omitted → no compose affordance. */
private onSteer?: (message: string) => void,
+ /** Read live cost-display preference. Omitted → hide reported cost. */
+ private showCost: () => boolean = () => false,
) {
this.keys = createViewerKeys(keybindings);
this.unsubscribe = session.subscribe(() => {
@@ -152,11 +154,15 @@ export class ConversationViewer implements Component {
const headerParts: string[] = [duration];
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
- const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
+ const usage = this.activity?.lifetimeUsage ?? this.record.lifetimeUsage;
+ const tokens = getLifetimeTotal(usage);
if (tokens > 0) {
const percent = getSessionContextPercent(this.activity?.session);
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
}
+ if (this.showCost() && usage?.cost !== undefined) {
+ headerParts.push(formatCost(usage.cost));
+ }
lines.push(row(
`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
diff --git a/src/ui/fleet-list.ts b/src/ui/fleet-list.ts
index 3727b05e..29f16c85 100644
--- a/src/ui/fleet-list.ts
+++ b/src/ui/fleet-list.ts
@@ -14,7 +14,7 @@
import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import type { AgentManager } from "../agent-manager.js";
import type { AgentRecord } from "../types.js";
-import { getLifetimeTotal } from "../usage.js";
+import { formatCost, getLifetimeTotal } from "../usage.js";
import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
@@ -93,6 +93,7 @@ export class FleetList {
constructor(
private manager: AgentManager,
private agentActivity: Map,
+ private showCost: () => boolean = () => false,
) {}
// ---- Lifecycle ----
@@ -310,6 +311,7 @@ export class FleetList {
},
keybindings,
(message: string) => this.manager.steer(record.id, message),
+ this.showCost,
);
},
{
@@ -372,9 +374,14 @@ export class FleetList {
private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", getDisplayName(record.type))} ${record.description}`;
- const tokens = getLifetimeTotal(this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage);
+ const usage = this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage;
+ const tokens = getLifetimeTotal(usage);
const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished
- const right = theme.fg("dim", `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}`);
+ const cost = this.showCost() ? formatCost(usage.cost) : "";
+ const stats = [formatFleetElapsed(elapsedMs), formatFleetTokens(tokens), cost]
+ .filter(Boolean)
+ .join(" · ");
+ const right = theme.fg("dim", stats);
return rightAlign(left, right, width);
}
}
diff --git a/src/usage.ts b/src/usage.ts
index 9fe075a9..152e0087 100644
--- a/src/usage.ts
+++ b/src/usage.ts
@@ -5,9 +5,10 @@
* compaction (which replaces session.state.messages and would reset any
* stats-derived sum). cacheRead is excluded because each turn's cacheRead is
* the cumulative cached prefix re-read on that one call — summing across
- * turns counts the prefix N times. See issue #38.
+ * turns counts the prefix N times. See issue #38. Reported model cost is
+ * accumulated separately when the provider supplies it.
*/
-export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
+export type LifetimeUsage = { input: number; output: number; cacheWrite: number; cost?: number };
/** Sum of lifetime usage components, or 0 if undefined. */
export function getLifetimeTotal(u?: LifetimeUsage): number {
@@ -19,6 +20,12 @@ export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
into.input += delta.input;
into.output += delta.output;
into.cacheWrite += delta.cacheWrite;
+ if (delta.cost !== undefined) into.cost = (into.cost ?? 0) + delta.cost;
+}
+
+/** Format a reported model cost for compact UI display, or "" when unavailable. */
+export function formatCost(cost?: number): string {
+ return cost !== undefined && Number.isFinite(cost) ? `~$${cost.toFixed(3)}` : "";
}
/** Minimal shape we read from upstream `getSessionStats()`. */
diff --git a/test/agent-manager.test.ts b/test/agent-manager.test.ts
index 35e20563..531c687c 100644
--- a/test/agent-manager.test.ts
+++ b/test/agent-manager.test.ts
@@ -582,8 +582,8 @@ describe("AgentManager — lifetime usage + compaction count are eagerly initial
vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, opts: any) => {
captured = opts;
// Two assistant messages with usage
- opts.onAssistantUsage?.({ input: 100, output: 50, cacheWrite: 10 });
- opts.onAssistantUsage?.({ input: 200, output: 80, cacheWrite: 20 });
+ opts.onAssistantUsage?.({ input: 100, output: 50, cacheWrite: 10, cost: 0.012 });
+ opts.onAssistantUsage?.({ input: 200, output: 80, cacheWrite: 20, cost: 0.006 });
return { responseText: "done", session: mockSession(), aborted: false, steered: false };
});
@@ -594,9 +594,11 @@ describe("AgentManager — lifetime usage + compaction count are eagerly initial
await manager.getRecord(id)!.promise;
expect(captured).toBeDefined();
- expect(manager.getRecord(id)!.lifetimeUsage).toEqual({
- input: 300, output: 130, cacheWrite: 30,
- });
+ const usage = manager.getRecord(id)!.lifetimeUsage;
+ expect(usage.input).toBe(300);
+ expect(usage.output).toBe(130);
+ expect(usage.cacheWrite).toBe(30);
+ expect(usage.cost).toBeCloseTo(0.018);
});
it("onCompaction from runAgent increments record.compactionCount", async () => {
diff --git a/test/agent-runner.test.ts b/test/agent-runner.test.ts
index 34790f08..099929d9 100644
--- a/test/agent-runner.test.ts
+++ b/test/agent-runner.test.ts
@@ -533,6 +533,24 @@ describe("agent-runner usage callback wiring", () => {
expect(seen).toEqual([{ input: 50, output: 0, cacheWrite: 0 }]);
});
+ it("runAgent forwards reported cost when message_end includes it", async () => {
+ const { session, listeners } = createSession("OK");
+ createAgentSession.mockResolvedValue({ session });
+
+ const seen: any[] = [];
+ session.prompt = vi.fn(async () => {
+ emitMessageEnd(listeners, { input: 50, output: 10, cacheWrite: 2, cost: { total: 0.018 } });
+ session.messages.push({ role: "assistant", content: [{ type: "text", text: "OK" }] });
+ });
+
+ await runAgent(ctx, "Explore", "go", {
+ pi,
+ onAssistantUsage: (u) => seen.push(u),
+ });
+
+ expect(seen).toEqual([{ input: 50, output: 10, cacheWrite: 2, cost: 0.018 }]);
+ });
+
it("runAgent skips the callback when message_end has no usage field", async () => {
const { session, listeners } = createSession("OK");
createAgentSession.mockResolvedValue({ session });
@@ -553,7 +571,7 @@ describe("agent-runner usage callback wiring", () => {
const seen: any[] = [];
session.prompt = vi.fn(async () => {
- emitMessageEnd(listeners, { input: 10, output: 20, cacheWrite: 5 });
+ emitMessageEnd(listeners, { input: 10, output: 20, cacheWrite: 5, cost: { total: 0.004 } });
session.messages.push({ role: "assistant", content: [{ type: "text", text: "RESUMED" }] });
});
@@ -561,7 +579,7 @@ describe("agent-runner usage callback wiring", () => {
onAssistantUsage: (u) => seen.push(u),
});
- expect(seen).toEqual([{ input: 10, output: 20, cacheWrite: 5 }]);
+ expect(seen).toEqual([{ input: 10, output: 20, cacheWrite: 5, cost: 0.004 }]);
});
it("forwards compaction_end events to onCompaction (only when not aborted)", async () => {
diff --git a/test/agent-widget.test.ts b/test/agent-widget.test.ts
index 6bed6cf1..83f1284f 100644
--- a/test/agent-widget.test.ts
+++ b/test/agent-widget.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { renderRunningAgentStatus } from "../src/index.js";
+import { formatGroupSummary, renderRunningAgentStatus } from "../src/index.js";
import type { WidgetMode } from "../src/types.js";
import { type AgentActivity, AgentWidget, fgPreservingNestedStyles, formatSessionTokens } from "../src/ui/agent-widget.js";
@@ -54,28 +54,55 @@ describe("renderRunningAgentStatus", () => {
});
});
+describe("formatGroupSummary", () => {
+ const summary = {
+ count: 3,
+ partial: false,
+ totalTokens: 42_200,
+ totalCost: 0.042,
+ };
+
+ it("shows aggregate token usage without cost when cost display is disabled", () => {
+ expect(formatGroupSummary(summary, false)).toBe("3 agents completed · 42.2k token");
+ });
+
+ it("includes aggregate cost only when cost display is enabled", () => {
+ expect(formatGroupSummary(summary, true)).toBe("3 agents completed · 42.2k token · ~$0.042");
+ });
+
+ it("marks partial groups", () => {
+ expect(formatGroupSummary({ ...summary, partial: true }, false)).toBe("3 agents completed (partial) · 42.2k token");
+ });
+});
+
describe("AgentWidget", () => {
const theme = { fg: (_c: string, s: string) => s, bold: (s: string) => s };
- function makeActivity(): AgentActivity {
+ function makeActivity(cost?: number): AgentActivity {
return {
activeTools: new Map(),
toolUses: 0,
responseText: "",
turnCount: 1,
- lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost },
};
}
- function makeRecord(id: string, opts: { isBackground?: boolean; parentAgentId?: string } = {}) {
+ function makeRecord(id: string, opts: {
+ isBackground?: boolean;
+ parentAgentId?: string;
+ status?: "running" | "steered";
+ lifetimeUsage?: { input: number; output: number; cacheWrite: number; cost?: number };
+ } = {}) {
return {
id,
type: "general-purpose",
description: `${id} description`,
- status: "running",
+ status: opts.status ?? "running",
toolUses: 0,
startedAt: Date.now(),
- lifetimeUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ completedAt: opts.status === "steered" ? Date.now() : undefined,
+ lifetimeUsage: opts.lifetimeUsage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
compactionCount: 0,
isBackground: opts.isBackground,
parentAgentId: opts.parentAgentId,
@@ -83,11 +110,12 @@ describe("AgentWidget", () => {
}
/** Render the widget for a manager and return the produced lines ("" if nothing rendered). */
- function renderLines(manager: unknown, activityId: string, mode?: () => WidgetMode): string {
+ function renderLines(manager: unknown, activityId: string, mode?: () => WidgetMode, showCost?: () => boolean, cost?: number): string {
const widget = new AgentWidget(
manager as any,
- new Map([[activityId, makeActivity()]]),
+ new Map([[activityId, makeActivity(cost)]]),
mode,
+ showCost,
);
let factory: any;
widget.setUICtx({
@@ -139,6 +167,30 @@ describe("AgentWidget", () => {
});
// "off" hides the widget entirely — even a background agent renders nothing.
+ it("shows cost only when cost display is enabled", () => {
+ const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] };
+ expect(renderLines(manager, "background", () => "background", () => false, 0.018)).not.toContain("~$0.018");
+ expect(renderLines(manager, "background", () => "background", () => true, 0.018)).toContain("~$0.018");
+ });
+
+ it("keeps lifetime tokens and cost on a finished turn-limited agent", () => {
+ const manager = {
+ listAgents: () => [makeRecord("limited", {
+ isBackground: true,
+ status: "steered",
+ lifetimeUsage: { input: 1_000, output: 200, cacheWrite: 0, cost: 0.018 },
+ })],
+ };
+ const lines = renderLines(manager, "limited", () => "background", () => true);
+ const hiddenCostLines = renderLines(manager, "limited", () => "background", () => false);
+
+ expect(lines).toContain("1.2k token");
+ expect(lines).toContain("~$0.018");
+ expect(lines).toContain("turn limit");
+ expect(hiddenCostLines).toContain("1.2k token");
+ expect(hiddenCostLines).not.toContain("~$0.018");
+ });
+
it("renders nothing in 'off' mode", () => {
const manager = { listAgents: () => [makeRecord("background", { isBackground: true })] };
expect(renderLines(manager, "background", () => "off")).toBe("");
diff --git a/test/conversation-viewer.test.ts b/test/conversation-viewer.test.ts
index d1aedad2..238f085b 100644
--- a/test/conversation-viewer.test.ts
+++ b/test/conversation-viewer.test.ts
@@ -257,6 +257,42 @@ describe("ConversationViewer", () => {
});
});
+ describe("cost display", () => {
+ it("shows reported lifetime cost when enabled", () => {
+ const viewer = new ConversationViewer(
+ mockTui(),
+ mockSession(),
+ mockRecord({ lifetimeUsage: { input: 1_000, output: 200, cacheWrite: 0, cost: 0.018 } }),
+ undefined,
+ ansiTheme(),
+ vi.fn(),
+ undefined,
+ undefined,
+ undefined,
+ () => true,
+ );
+
+ expect(viewer.render(80).join("\n")).toContain("~$0.018");
+ });
+
+ it("hides reported lifetime cost when disabled", () => {
+ const viewer = new ConversationViewer(
+ mockTui(),
+ mockSession(),
+ mockRecord({ lifetimeUsage: { input: 1_000, output: 200, cacheWrite: 0, cost: 0.018 } }),
+ undefined,
+ ansiTheme(),
+ vi.fn(),
+ undefined,
+ undefined,
+ undefined,
+ () => false,
+ );
+
+ expect(viewer.render(80).join("\n")).not.toContain("~$0.018");
+ });
+ });
+
describe("safety net against upstream wrapTextWithAnsi bugs", () => {
// These tests call buildContentLines() directly (via the private method)
// because render() has its own truncation via row(). The safety net in
diff --git a/test/fleet-list.test.ts b/test/fleet-list.test.ts
index 7d91c286..210e8468 100644
--- a/test/fleet-list.test.ts
+++ b/test/fleet-list.test.ts
@@ -49,7 +49,7 @@ interface Harness {
ui: FleetUICtx;
manager: AgentManager;
/** The overlay component (a real ConversationViewer) once one is opened. */
- overlayComponent: () => { handleInput(data: string): void } | undefined;
+ overlayComponent: () => { handleInput(data: string): void; render(width: number): string[] } | undefined;
/** Feed a key to the registered input handler; returns the consume result. */
press: (data: string) => { consume?: boolean } | undefined;
/** Render the currently-registered below-editor widget at the given width. */
@@ -65,7 +65,7 @@ interface Harness {
widgetTui: { requestRender(): void; focusedComponent?: unknown };
}
-function harness(agents: AgentRecord[]): Harness {
+function harness(agents: AgentRecord[], showCost = false): Harness {
let inputHandler: ((data: string) => { consume?: boolean } | undefined) | undefined;
let widgetFactory: ((tui: any, theme: any) => { render(w: number): string[] }) | undefined;
let editorText = "";
@@ -93,7 +93,7 @@ function harness(agents: AgentRecord[]): Harness {
};
const manager = fakeManager(agents);
- const fleet = new FleetList(manager, new Map());
+ const fleet = new FleetList(manager, new Map(), () => showCost);
fleet.setUICtx(ui);
fleet.update();
@@ -315,6 +315,35 @@ describe("FleetList rendering", () => {
expect(agentLine).toMatch(/\d+s · ↓/); // "s · ↓ ..." (timing-agnostic)
});
+ it("shows reported cost when cost display is enabled", () => {
+ const h = harness([
+ makeRecord({ lifetimeUsage: { input: 13_100, output: 0, cacheWrite: 0, cost: 0.018 } }),
+ ], true);
+ const agentLine = h.render(120).find(l => l.includes("Sleep then report 1"))!;
+ expect(agentLine).toContain("~$0.018");
+ });
+
+ it("passes cost display to the selected agent popup", () => {
+ const h = harness([
+ makeRecord({ lifetimeUsage: { input: 13_100, output: 0, cacheWrite: 0, cost: 0.018 } }),
+ ], true);
+ h.press(DOWN); // activate (main)
+ h.press(DOWN); // select the agent
+ h.press(ENTER); // open the conversation popup
+
+ // The harness theme uses visible markup instead of ANSI escapes; use a wide
+ // render width so that markup does not affect the viewer's truncation path.
+ expect(h.overlayComponent()!.render(500).join("\n")).toContain("~$0.018");
+ });
+
+ it("hides reported cost when cost display is disabled", () => {
+ const h = harness([
+ makeRecord({ lifetimeUsage: { input: 13_100, output: 0, cacheWrite: 0, cost: 0.018 } }),
+ ]);
+ const agentLine = h.render(120).find(l => l.includes("Sleep then report 1"))!;
+ expect(agentLine).not.toContain("~$0.018");
+ });
+
it("orders agents earliest-launched first (top)", () => {
const agents = [
makeRecord({ id: "new", description: "newest", startedAt: 2000 }),
diff --git a/test/settings.test.ts b/test/settings.test.ts
index af83b841..d19098b4 100644
--- a/test/settings.test.ts
+++ b/test/settings.test.ts
@@ -284,6 +284,38 @@ describe("settings persistence", () => {
expect(loadSettings(projectDir).scopeModels).toBeUndefined();
});
+ it("accepts showCost boolean (true and false)", () => {
+ writeProject({ showCost: true });
+ expect(loadSettings(projectDir)).toEqual({ showCost: true });
+ writeProject({ showCost: false });
+ expect(loadSettings(projectDir)).toEqual({ showCost: false });
+ });
+
+ it("drops non-boolean showCost", () => {
+ writeProject({ showCost: "yes" });
+ expect(loadSettings(projectDir).showCost).toBeUndefined();
+ writeProject({ showCost: 1 });
+ expect(loadSettings(projectDir).showCost).toBeUndefined();
+ writeProject({ showCost: null });
+ expect(loadSettings(projectDir).showCost).toBeUndefined();
+ });
+
+ it("accepts showGroupSummary boolean (true and false)", () => {
+ writeProject({ showGroupSummary: true });
+ expect(loadSettings(projectDir)).toEqual({ showGroupSummary: true });
+ writeProject({ showGroupSummary: false });
+ expect(loadSettings(projectDir)).toEqual({ showGroupSummary: false });
+ });
+
+ it("drops non-boolean showGroupSummary", () => {
+ writeProject({ showGroupSummary: "yes" });
+ expect(loadSettings(projectDir).showGroupSummary).toBeUndefined();
+ writeProject({ showGroupSummary: 1 });
+ expect(loadSettings(projectDir).showGroupSummary).toBeUndefined();
+ writeProject({ showGroupSummary: null });
+ expect(loadSettings(projectDir).showGroupSummary).toBeUndefined();
+ });
+
it("accepts disableDefaultAgents boolean (true and false)", () => {
writeProject({ disableDefaultAgents: true });
expect(loadSettings(projectDir)).toEqual({ disableDefaultAgents: true });
@@ -420,6 +452,8 @@ describe("settings persistence", () => {
setOutputTranscript: vi.fn(),
setMaxSubagentDepth: vi.fn(),
setFallbackSubagent: vi.fn(),
+ setShowCost: vi.fn(),
+ setShowGroupSummary: vi.fn(),
};
});
@@ -466,6 +500,8 @@ describe("settings persistence", () => {
toolDescriptionMode: "compact",
fleetView: false,
widgetMode: "off",
+ showCost: true,
+ showGroupSummary: true,
},
appliers,
);
@@ -479,6 +515,8 @@ describe("settings persistence", () => {
expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("compact");
expect(appliers.setFleetView).toHaveBeenCalledWith(false);
expect(appliers.setWidgetMode).toHaveBeenCalledWith("off");
+ expect(appliers.setShowCost).toHaveBeenCalledWith(true);
+ expect(appliers.setShowGroupSummary).toHaveBeenCalledWith(true);
});
it("applies widgetMode; skips it when absent", () => {
@@ -517,6 +555,13 @@ describe("settings persistence", () => {
expect(appliers.setOutputTranscript).toHaveBeenCalledWith(true);
});
+ it("applies showGroupSummary (both true and false)", () => {
+ applySettings({ showGroupSummary: false }, appliers);
+ expect(appliers.setShowGroupSummary).toHaveBeenCalledWith(false);
+ applySettings({ showGroupSummary: true }, appliers);
+ expect(appliers.setShowGroupSummary).toHaveBeenCalledWith(true);
+ });
+
it("applies defaultMaxTurns: 0 as the explicit unlimited marker", () => {
applySettings({ defaultMaxTurns: 0 }, appliers);
expect(appliers.setDefaultMaxTurns).toHaveBeenCalledWith(0);
@@ -578,6 +623,8 @@ describe("settings persistence", () => {
setOutputTranscript: vi.fn(),
setMaxSubagentDepth: vi.fn(),
setFallbackSubagent: vi.fn(),
+ setShowCost: vi.fn(),
+ setShowGroupSummary: vi.fn(),
};
});
diff --git a/test/usage.test.ts b/test/usage.test.ts
index 4300925e..aa9eba1d 100644
--- a/test/usage.test.ts
+++ b/test/usage.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { getLifetimeTotal, getSessionContextPercent, getSessionTokens } from "../src/usage.js";
+import { addUsage, formatCost, getLifetimeTotal, getSessionContextPercent, getSessionTokens } from "../src/usage.js";
// Regression for issue #38 — token semantics + context indicator
describe("usage", () => {
@@ -50,6 +50,26 @@ describe("usage", () => {
});
});
+ describe("cost", () => {
+ it("accumulates reported cost without affecting token totals", () => {
+ const usage = { input: 100, output: 20, cacheWrite: 5 };
+ addUsage(usage, { input: 10, output: 2, cacheWrite: 1, cost: 0.012 });
+ addUsage(usage, { input: 20, output: 3, cacheWrite: 0, cost: 0.006 });
+
+ expect(usage.input).toBe(130);
+ expect(usage.output).toBe(25);
+ expect(usage.cacheWrite).toBe(6);
+ expect(usage.cost).toBeCloseTo(0.018);
+ expect(getLifetimeTotal(usage)).toBe(161);
+ });
+
+ it("formats reported cost and omits unavailable values", () => {
+ expect(formatCost(0.018)).toBe("~$0.018");
+ expect(formatCost(undefined)).toBe("");
+ expect(formatCost(Number.NaN)).toBe("");
+ });
+ });
+
describe("getLifetimeTotal", () => {
it("sums components and handles undefined", () => {
expect(getLifetimeTotal(undefined)).toBe(0);