From da168b402226cd22b75550f97dc24328f30f771b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elberte=20Pl=C3=ADnio?= Date: Wed, 22 Jul 2026 00:10:06 -0300 Subject: [PATCH] feat(pi-kit): add footer session metrics --- bun.lock | 16 +- packages/pi-kit/extensions/ember-footer.ts | 260 +++++++++++++++------ packages/pi-kit/package.json | 6 +- packages/pi-kit/test/ember-footer.test.ts | 169 ++++++++++++++ 4 files changed, 369 insertions(+), 82 deletions(-) create mode 100644 packages/pi-kit/test/ember-footer.test.ts diff --git a/bun.lock b/bun.lock index 37f06e6..32d9414 100644 --- a/bun.lock +++ b/bun.lock @@ -44,9 +44,9 @@ "name": "@pickforge/pi-kit", "version": "0.1.0", "devDependencies": { - "@earendil-works/pi-ai": "0.79.10", - "@earendil-works/pi-coding-agent": "0.79.10", - "@earendil-works/pi-tui": "0.79.10", + "@earendil-works/pi-ai": "0.81.0", + "@earendil-works/pi-coding-agent": "0.81.0", + "@earendil-works/pi-tui": "0.81.0", "typebox": "1.1.38", "typescript": "^5.6.0", "vitest": "^2.0.0", @@ -127,13 +127,13 @@ "@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="], - "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q=="], + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.81.1", "", { "dependencies": { "@earendil-works/pi-ai": "^0.81.1", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-yqbh68CyhqxMov/jUogFJfMqlu2Gd37GAki+tr59YCmAPHfomiCA5ESzusXtpGzABeiZFC/OrRdQ4GwCCOMIHA=="], - "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew=="], + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.81.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-n3lDV1Px/2BOp86rUJkoHcXuQ6uf7711VEtSjE5gTwrnPeMAEjzV3LnbQ3wTF9bUxl253Igi3U35U1CMF5POng=="], - "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.10", "@earendil-works/pi-ai": "^0.79.10", "@earendil-works/pi-tui": "^0.79.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA=="], + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.81.0", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.81.0", "@earendil-works/pi-ai": "^0.81.0", "@earendil-works/pi-tui": "^0.81.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-2p0Dnx+3fkPLga8M82eg14ZYNLcFLhqxxKyVVfqUSIio9Xx4p7UjvJtopx/6PTeJKmdJl1/xOm/c02AFcJ+l/g=="], - "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw=="], + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.81.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-vdrrV//CldG3Dt20vj+iCRcHOlPwxlOBJwwLg1KE/92IO+TlEwbf4uwKXgFZHGVh9lEJXtYsKzIRb/nHw5YuAA=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -741,6 +741,8 @@ "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.7", "", { "dependencies": { "@smithy/core": "^3.29.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA=="], + "@earendil-works/pi-agent-core/@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.81.1", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-hzHE7Z8l5mgJk+ke67Lge0rwS2+wbKJrFKl9o5M1R1rh33+cCT7D1AHz1OAtX5wFs90E1/BTGhyJRTUHaMxGvQ=="], + "@pickforge/pi-kit/vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="], "@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], diff --git a/packages/pi-kit/extensions/ember-footer.ts b/packages/pi-kit/extensions/ember-footer.ts index 60d5159..637bee6 100644 --- a/packages/pi-kit/extensions/ember-footer.ts +++ b/packages/pi-kit/extensions/ember-footer.ts @@ -9,7 +9,7 @@ * Toggle with /footer. Requires pi-bar to be uninstalled or hidden, since * both replace the footer. */ -import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { calculateCost, type AssistantMessage, type Usage } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; @@ -22,26 +22,59 @@ function fmtTokens(count: number): string { return `${count}`; } +function fmtDuration(elapsedMs: number): string { + const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000)); + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + const clock = `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`; + return hours > 0 ? `${hours}:${clock}` : clock; +} + +function pricedCost(ctx: ExtensionContext, message: AssistantMessage): number { + if (message.usage.cost.total > 0) return message.usage.cost.total; + try { + const provider = message.provider; + const modelId = message.model; + if (!provider || !modelId || message.usage.totalTokens <= 0) return 0; + const model = ctx.modelRegistry.find(provider, modelId); + if (!model || !Object.values(model.cost).some((rate) => typeof rate === "number" && rate > 0)) return 0; + const estimatedUsage: Usage = { + ...message.usage, + cost: { ...message.usage.cost }, + }; + return calculateCost(model, estimatedUsage).total; + } catch { + return 0; + } +} + export default function emberFooter(pi: ExtensionAPI) { let enabled = true; // Cost accumulation is O(session length); cache it and refresh only when the - // branch entry count changes instead of walking the branch every render frame. + // entry count changes instead of walking the session every render frame. let cachedCost = 0; let cachedEntryCount = -1; - let cachedLeafId: string | null = null; + let sessionStartedAt = Date.now(); + let workingStartedAt: number | null = null; + let workedElapsedMs: number | null = null; + let clockTimer: ReturnType | undefined; + let requestRender: (() => void) | undefined; function sessionCost(ctx: ExtensionContext): number { try { - const branch = ctx.sessionManager.getBranch(); - const leafId = ctx.sessionManager.getLeafId(); - if (branch.length !== cachedEntryCount || leafId !== cachedLeafId) { - cachedEntryCount = branch.length; - cachedLeafId = leafId; + const entries = ctx.sessionManager.getEntries(); + if (entries.length !== cachedEntryCount) { + cachedEntryCount = entries.length; cachedCost = 0; - for (const entry of branch) { + for (const entry of entries) { if (entry.type === "message" && entry.message.role === "assistant") { - cachedCost += (entry.message as AssistantMessage).usage.cost.total; + cachedCost += pricedCost(ctx, entry.message as AssistantMessage); + } else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) { + cachedCost += entry.message.usage.cost.total; + } else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) { + cachedCost += entry.usage.cost.total; } } } @@ -51,82 +84,165 @@ export default function emberFooter(pi: ExtensionAPI) { return cachedCost; } + function stopClock(): void { + if (clockTimer) clearInterval(clockTimer); + clockTimer = undefined; + } + + function startClock(): void { + stopClock(); + clockTimer = setInterval(() => requestRender?.(), 1_000); + clockTimer.unref?.(); + } + function install(ctx: ExtensionContext): void { if (!ctx.hasUI) return; - ctx.ui.setFooter((tui, theme, footerData) => { - const unsubscribe = footerData.onBranchChange(() => tui.requestRender()); - const sep = theme.fg("dim", " · "); - - return { - dispose: unsubscribe, - invalidate() {}, - render(width: number): string[] { - const cost = sessionCost(ctx); - let contextTokens: number | null = null; - let contextWindow = 0; + try { + ctx.ui.setFooter((tui, theme, footerData) => { + const render = () => { try { - const usage = ctx.getContextUsage(); - contextTokens = usage?.tokens ?? null; - contextWindow = usage?.contextWindow ?? 0; + tui.requestRender(); } catch { - // Footer must never break the session. + // Rendering callbacks must never break the session. } + }; + requestRender = render; + const unsubscribe = footerData.onBranchChange(render); + const sep = theme.fg("dim", " · "); - const model = ctx.model?.id ?? "no-model"; - const thinking = pi.getThinkingLevel(); - const pressurePct = - contextTokens !== null && contextWindow > 0 ? (contextTokens / contextWindow) * 100 : null; - const pressureColor = pressurePct === null ? "muted" : pressurePct < 50 ? "success" : pressurePct < 80 ? "warning" : "error"; - const pressure = - pressurePct === null ? "—" : `${pressurePct.toFixed(0)}% of ${fmtTokens(contextWindow)}`; - const branch = footerData.getGitBranch(); - - const left = [ - theme.fg("accent", model), - theme.fg("muted", `think ${thinking}`), - theme.fg(pressureColor, pressure), - ].join(sep); - const right = [ - ...(branch ? [theme.fg("muted", ` ${branch}`)] : []), - theme.fg("dim", `$${cost.toFixed(2)}`), - ].join(sep); - - const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); - const lines = [truncateToWidth(left + pad + right, width)]; - - // Line 2: extension statuses, text only, deduplicated. - const seen = new Set(); - const statuses: string[] = []; - for (const [key, text] of footerData.getExtensionStatuses()) { - if (HIDDEN_STATUS_KEYS.has(key)) continue; - const clean = text.trim(); - if (!clean || seen.has(clean)) continue; - seen.add(clean); - statuses.push(clean); - } - if (statuses.length > 0) { - lines.push(truncateToWidth(statuses.join(sep), width)); - } - return lines; - }, - }; - }); + return { + dispose() { + try { + unsubscribe(); + } catch { + // Footer cleanup is best-effort. + } + if (requestRender === render) requestRender = undefined; + }, + invalidate() {}, + render(width: number): string[] { + const cost = sessionCost(ctx); + let contextTokens: number | null = null; + let contextWindow = 0; + try { + const usage = ctx.getContextUsage(); + contextTokens = usage?.tokens ?? null; + contextWindow = usage?.contextWindow ?? 0; + } catch { + // Footer must never break the session. + } + + const model = ctx.model?.id ?? "no-model"; + const thinking = pi.getThinkingLevel(); + const pressurePct = + contextTokens !== null && contextWindow > 0 ? (contextTokens / contextWindow) * 100 : null; + const pressureColor = + pressurePct === null ? "muted" : pressurePct < 50 ? "success" : pressurePct < 80 ? "warning" : "error"; + const pressure = + pressurePct === null ? "—" : `${pressurePct.toFixed(0)}% of ${fmtTokens(contextWindow)}`; + const branch = footerData.getGitBranch(); + const now = Date.now(); + let costText = `$${cost.toFixed(2)}`; + try { + if (ctx.model && ctx.modelRegistry.isUsingOAuth(ctx.model)) costText += " est"; + } catch { + // Footer must never break the session. + } + + const left = [ + theme.fg("accent", model), + theme.fg("muted", `think ${thinking}`), + theme.fg(pressureColor, pressure), + theme.fg("muted", `session ${fmtDuration(now - sessionStartedAt)}`), + ...(workingStartedAt !== null + ? [theme.fg("warning", `working ${fmtDuration(now - workingStartedAt)}`)] + : workedElapsedMs !== null + ? [theme.fg("muted", `worked ${fmtDuration(workedElapsedMs)}`)] + : []), + ].join(sep); + const right = [ + ...(branch ? [theme.fg("muted", ` ${branch}`)] : []), + theme.fg("dim", costText), + ].join(sep); + + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + const lines = [truncateToWidth(left + pad + right, width)]; + + // Line 2: extension statuses, text only, deduplicated. + const seen = new Set(); + const statuses: string[] = []; + for (const [key, text] of footerData.getExtensionStatuses()) { + if (HIDDEN_STATUS_KEYS.has(key)) continue; + const clean = text.trim(); + if (!clean || seen.has(clean)) continue; + seen.add(clean); + statuses.push(clean); + } + if (statuses.length > 0) { + lines.push(truncateToWidth(statuses.join(sep), width)); + } + return lines; + }, + }; + }); + } catch { + // Footer installation must never break the session. + } } - pi.on("session_start", (_event, ctx) => { + pi.on("session_start", async (_event, ctx) => { + sessionStartedAt = Date.now(); + workingStartedAt = null; + workedElapsedMs = null; + cachedEntryCount = -1; + stopClock(); + try { + await ctx.modelRegistry.refresh(); + } catch { + // Existing registry data is still safe to render. + } + if (ctx.hasUI && enabled) startClock(); if (enabled) install(ctx); }); + pi.on("agent_start", () => { + if (workingStartedAt === null) { + workedElapsedMs = null; + workingStartedAt = Date.now(); + } + requestRender?.(); + }); + + pi.on("agent_settled", () => { + if (workingStartedAt !== null) { + workedElapsedMs = Date.now() - workingStartedAt; + workingStartedAt = null; + } + requestRender?.(); + }); + + pi.on("session_shutdown", () => { + stopClock(); + workingStartedAt = null; + workedElapsedMs = null; + }); + pi.registerCommand("footer", { description: "Toggle the Pickforge ember footer", handler: async (_args, ctx) => { - enabled = !enabled; - if (enabled) { - install(ctx); - ctx.ui.notify("ember footer on", "info"); - } else { - ctx.ui.setFooter(undefined); - ctx.ui.notify("built-in footer restored", "info"); + try { + enabled = !enabled; + if (enabled) { + startClock(); + install(ctx); + ctx.ui.notify("ember footer on", "info"); + } else { + stopClock(); + ctx.ui.setFooter(undefined); + ctx.ui.notify("built-in footer restored", "info"); + } + } catch { + // Footer commands must never break the session. } }, }); diff --git a/packages/pi-kit/package.json b/packages/pi-kit/package.json index 4eb7c16..2dea576 100644 --- a/packages/pi-kit/package.json +++ b/packages/pi-kit/package.json @@ -25,11 +25,11 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@earendil-works/pi-coding-agent": "0.79.10", + "@earendil-works/pi-coding-agent": "0.81.0", "typescript": "^5.6.0", "vitest": "^2.0.0", "typebox": "1.1.38", - "@earendil-works/pi-ai": "0.79.10", - "@earendil-works/pi-tui": "0.79.10" + "@earendil-works/pi-ai": "0.81.0", + "@earendil-works/pi-tui": "0.81.0" } } \ No newline at end of file diff --git a/packages/pi-kit/test/ember-footer.test.ts b/packages/pi-kit/test/ember-footer.test.ts new file mode 100644 index 0000000..ce36963 --- /dev/null +++ b/packages/pi-kit/test/ember-footer.test.ts @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import emberFooter from "../extensions/ember-footer.ts"; + +type Handler = (event: unknown, ctx: ExtensionContext) => void | Promise; +type FooterFactory = (tui: { requestRender(): void }, theme: Theme, data: FooterData) => FooterComponent; +type FooterComponent = { render(width: number): string[]; dispose?(): void }; +type Theme = { fg(_color: string, text: string): string }; +type FooterData = { + onBranchChange(callback: () => void): () => void; + getGitBranch(): string | null; + getExtensionStatuses(): ReadonlyMap; +}; + +function usage(input: number, output: number, total = 0) { + return { + input, + output, + cacheRead: 0, + cacheWrite: 0, + reasoning: 0, + totalTokens: input + output, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total }, + }; +} + +function makeHarness(opts: { stalePricing?: boolean; throwRender?: boolean } = {}) { + const handlers = new Map(); + let footerFactory: FooterFactory | undefined; + let renders = 0; + const entries = [ + { + type: "message", + message: { + role: "assistant", + provider: "openai-codex", + model: "gpt-5.6-sol", + usage: usage(1_000, 500), + }, + }, + ]; + const pricedModel = { + provider: "openai-codex", + id: "gpt-5.6-sol", + cost: { input: 5, output: 30, cacheRead: 0.5, cacheWrite: 6.25 }, + }; + const staleModel = { ...pricedModel, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }; + const model = opts.stalePricing ? staleModel : pricedModel; + let registeredModel = model; + const ctx = { + hasUI: true, + model, + modelRegistry: { + find: (provider: string, id: string) => + provider === model.provider && id === model.id ? registeredModel : undefined, + refresh: async () => { + await Promise.resolve(); + registeredModel = pricedModel; + }, + isUsingOAuth: () => true, + }, + getContextUsage: () => ({ tokens: 10_000, contextWindow: 272_000 }), + sessionManager: { + getEntries: () => entries, + getBranch: () => entries, + getLeafId: () => "leaf", + }, + ui: { + setFooter: (factory: FooterFactory | undefined) => { + footerFactory = factory; + }, + notify: () => {}, + }, + } as unknown as ExtensionContext; + const pi = { + getThinkingLevel: () => "high", + on: (name: string, handler: Handler) => handlers.set(name, handler), + registerCommand: () => {}, + } as unknown as ExtensionAPI; + emberFooter(pi); + + const emit = async (name: string, event: unknown = {}) => { + await handlers.get(name)?.(event, ctx); + }; + const render = () => { + if (!footerFactory) throw new Error("footer not installed"); + const component = footerFactory( + { + requestRender: () => { + if (opts.throwRender) throw new Error("render unavailable"); + renders++; + }, + }, + { fg: (_color, text) => text }, + { + onBranchChange: () => () => {}, + getGitBranch: () => null, + getExtensionStatuses: () => new Map(), + }, + ); + return component.render(200).join("\n"); + }; + + return { emit, render, getRenderRequests: () => renders }; +} + +describe("ember footer", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-21T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("recomputes missing persisted cost from the message model pricing", async () => { + const { emit, render } = makeHarness(); + await emit("session_start"); + + expect(render()).toContain("$0.02 est"); + }); + + it("refreshes stale model pricing when extensions reload", async () => { + const { emit, render } = makeHarness({ stalePricing: true }); + await emit("session_start"); + + expect(render()).toContain("$0.02 est"); + }); + + it("contains timer and agent render callback failures", async () => { + const { emit, render } = makeHarness({ throwRender: true }); + await emit("session_start"); + render(); + + await expect(emit("agent_start")).resolves.toBeUndefined(); + expect(() => vi.advanceTimersByTime(1_000)).not.toThrow(); + await expect(emit("agent_settled")).resolves.toBeUndefined(); + }); + + it("times the whole agent run and retains the final worked duration", async () => { + const { emit, render, getRenderRequests } = makeHarness(); + await emit("session_start"); + expect(render()).toContain("session 00:00"); + expect(render()).not.toContain("working"); + expect(render()).not.toContain("worked"); + + emit("agent_start"); + vi.advanceTimersByTime(30_000); + emit("message_end", { message: { role: "assistant" } }); + vi.advanceTimersByTime(35_000); + expect(render()).toContain("session 01:05"); + expect(render()).toContain("working 01:05"); + expect(getRenderRequests()).toBeGreaterThan(0); + + emit("agent_settled"); + expect(render()).toContain("worked 01:05"); + expect(render()).not.toContain("working"); + vi.advanceTimersByTime(10_000); + expect(render()).toContain("worked 01:05"); + + emit("agent_start"); + expect(render()).toContain("working 00:00"); + expect(render()).not.toContain("worked"); + emit("session_shutdown"); + expect(vi.getTimerCount()).toBe(0); + }); +});