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);