diff --git a/package-lock.json b/package-lock.json index 3557c68..fe95ab5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23329,7 +23329,7 @@ }, "packages/pi-token-tank": { "name": "@iurysza/pi-token-tank", - "version": "0.6.2", + "version": "0.6.3", "license": "MIT", "devDependencies": { "@earendil-works/pi-coding-agent": "^0.80.9", diff --git a/packages/pi-ext/extensions/custom-footer/custom-footer.ts b/packages/pi-ext/extensions/custom-footer/custom-footer.ts index 2bd9d27..67b5c2d 100644 --- a/packages/pi-ext/extensions/custom-footer/custom-footer.ts +++ b/packages/pi-ext/extensions/custom-footer/custom-footer.ts @@ -1,205 +1,178 @@ /** - * Custom Footer Extension — Two-line compact powerline style + * Two-line custom footer. * - * Line 1: MODE ~/path (branch) │ 42%/200k │ ended 3:59 pm · 1m 50s ago │ ⚡ model • thinking - * Line 2: Extension statuses registered through ctx.ui.setStatus(). - * - * Rendered as a belowEditor widget while the default footer is suppressed. + * Line 1 owns core session state. Line 2 packs extension statuses registered + * through the optional footer-slot metadata protocol; ordinary setStatus() + * values remain visible as legacy priority-zero slots. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import type { PermissionMode } from "../permissions/permissions.js"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; -import { execSync } from "node:child_process"; -import { existsSync, watch, type FSWatcher } from "node:fs"; -import { join } from "node:path"; +import type { PermissionMode } from "../permissions/permissions.js"; import { - buildPathString, - fmtTokens, - modePillWidth, - renderContextUsage, - renderModelInfo, - renderModePill, - renderPath, + buildPathString, + fmtTokens, + modePillWidth, + renderContextUsage, + renderModelInfo, + renderModePill, + renderPath, } from "./renderers.js"; - - - -// ── Helpers ──────────────────────────────────────────────────────────── - -function getGitBranch(cwd: string): string | null { - try { - return execSync("git branch --show-current", { cwd, encoding: "utf-8", timeout: 500 }).trim() || null; - } catch { - return null; - } +import { createFooterSlotRegistry, packFooterStatuses } from "./footer-slots.js"; + +type FooterData = { + getExtensionStatuses(): ReadonlyMap; + getGitBranch(): string | undefined; + onBranchChange(callback: () => void): () => void; +}; + +type FooterTheme = { + fg(role: any, text: string): string; + bold(text: string): string; + inverse(text: string): string; + bg(role: any, text: string): string; +}; + +export function formatResponseTime(endedAt: number): string { + const time = new Date(endedAt).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }).toLowerCase(); + const elapsedSeconds = Math.max(0, Math.floor((Date.now() - endedAt) / 1000)); + if (elapsedSeconds < 60) return `◷ ended ${time} · ${elapsedSeconds}s ago`; + + const elapsedMinutes = Math.floor(elapsedSeconds / 60); + if (elapsedMinutes < 60) return `◷ ended ${time} · ${elapsedMinutes}m ${elapsedSeconds % 60}s ago`; + + const elapsedHours = Math.floor(elapsedMinutes / 60); + if (elapsedHours < 24) return `◷ ended ${time} · ${elapsedHours}h ${elapsedMinutes % 60}m ago`; + return `◷ ended ${time} · ${Math.floor(elapsedHours / 24)}d ${elapsedHours % 24}h ago`; } -function formatResponseTime(endedAt: number): string { - const time = new Date(endedAt).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - }).toLowerCase(); - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - endedAt) / 1000)); - - if (elapsedSeconds < 60) return `◷ ended ${time} · ${elapsedSeconds}s ago`; - - const elapsedMinutes = Math.floor(elapsedSeconds / 60); - if (elapsedMinutes < 60) return `◷ ended ${time} · ${elapsedMinutes}m ${elapsedSeconds % 60}s ago`; - - const elapsedHours = Math.floor(elapsedMinutes / 60); - if (elapsedHours < 24) return `◷ ended ${time} · ${elapsedHours}h ${elapsedMinutes % 60}m ago`; - - return `◷ ended ${time} · ${Math.floor(elapsedHours / 24)}d ${elapsedHours % 24}h ago`; -} - -// ── Extension ────────────────────────────────────────────────────────── - -export default function (pi: ExtensionAPI) { - let currentMode: PermissionMode = "safe"; - let tuiRef: { requestRender(): void } | null = null; - let footerDataRef: { getExtensionStatuses(): ReadonlyMap } | null = null; - let gitBranch: string | null = null; - let gitWatcher: FSWatcher | undefined; - let responseEndedAt: number | null = null; - let responseAgeTimer: ReturnType | undefined; - - pi.events.on("mode:change", (data: unknown) => { - currentMode = data as PermissionMode; - tuiRef?.requestRender(); - }); - - pi.on("session_start", async (_event, ctx) => { - responseAgeTimer = setInterval(() => { - if (responseEndedAt !== null) tuiRef?.requestRender(); - }, 1000); - responseAgeTimer.unref?.(); - - // Get initial git branch - gitBranch = getGitBranch(ctx.cwd); - - // Watch .git/HEAD for branch changes - const headPath = join(ctx.cwd, ".git", "HEAD"); - if (existsSync(headPath)) { - gitWatcher = watch(headPath, () => { - gitBranch = getGitBranch(ctx.cwd); - tuiRef?.requestRender(); - }); - } - - // Suppress the default footer, but retain its data provider so extension - // statuses remain visible in our below-editor footer. - ctx.ui.setFooter((_footerTui, _footerTheme, footerData) => { - footerDataRef = footerData; - return { - render() { return []; }, - invalidate() { tuiRef?.requestRender(); }, - }; - }); - - const setWidgetFn = ctx.ui.setWidget.bind(ctx.ui) as ( - name: string, - content: unknown, - options?: { placement?: string }, - ) => void; - - setWidgetFn( - "custom-footer", - (_widgetTui: { requestRender(): void }, widgetTheme: any) => { - tuiRef = _widgetTui; - return { - render(width: number): string[] { - const lines = [renderLine1(width, widgetTheme, ctx)]; - const statusLine = renderExtensionStatuses(width); - if (statusLine) lines.push(statusLine); - return lines; - }, - invalidate() {}, - }; - }, - { placement: "belowEditor" }, - ); - }); - - pi.on("agent_start", () => { - responseEndedAt = null; - tuiRef?.requestRender(); - }); - - pi.on("agent_end", () => { - responseEndedAt = Date.now(); - tuiRef?.requestRender(); - }); - - pi.on("session_shutdown", async () => { - gitWatcher?.close(); - if (responseAgeTimer) clearInterval(responseAgeTimer); - responseAgeTimer = undefined; - footerDataRef = null; - tuiRef = null; - }); - - function renderExtensionStatuses(width: number): string | null { - const statuses = footerDataRef?.getExtensionStatuses(); - if (!statuses || statuses.size === 0) return null; - - const text = Array.from(statuses.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([, value]) => value.replace(/[\r\n\t]+/g, " ").trim()) - .filter(Boolean) - .join(" "); - - return text ? truncateToWidth(` ${text}`, width) : null; - } - - // ── Line 1: Mode │ Path │ Context │ Model ────────────────────────── - - function renderLine1( - width: number, - theme: { fg: (role: any, text: string) => string; bold: (text: string) => string; inverse: (text: string) => string; bg: (role: any, text: string) => string }, - ctx: { getContextUsage(): { percent: number | null; contextWindow: number } | null | undefined; model: { provider?: string; id?: string; contextWindow?: number } | null | undefined }, - ): string { - const sep = theme.fg("dim", " │ "); - const sepW = 3; - - // Mode pill - const pill = renderModePill(currentMode, theme); - const pillW = modePillWidth(currentMode); - - // Path + branch - const pathRaw = buildPathString(process.cwd(), gitBranch); - - // Context usage - const usage = ctx.getContextUsage(); - const pct = usage?.percent ?? 0; - const win = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0; - const ctxRaw = `${pct.toFixed(0)}%/${fmtTokens(win)}`; - const ctxColored = renderContextUsage(pct, win, theme); - - // Model + thinking - const provider = ctx.model?.provider || "unknown"; - const modelName = ctx.model?.id || "no-model"; - const thinking = pi.getThinkingLevel(); - const model = renderModelInfo(modelName, provider, thinking, theme); - const responseTimeRaw = responseEndedAt === null ? "" : formatResponseTime(responseEndedAt); - const responseTime = responseTimeRaw ? theme.fg("dim", responseTimeRaw) : ""; - - // Layout: compute path budget from remaining space - const responseTimeWidth = responseTimeRaw ? sepW + visibleWidth(responseTimeRaw) : 0; - const rightBlockWidth = visibleWidth(ctxRaw) + responseTimeWidth + sepW + model.rawWidth; - const pathBudget = width - pillW - sepW - rightBlockWidth - sepW; - const pathDisplay = renderPath(pathRaw, pathBudget, theme); - - // Assemble - const segments: string[] = [pill]; - if (pathDisplay) segments.push(pathDisplay); - segments.push(ctxColored); - if (responseTime) segments.push(responseTime); - segments.push(model.text); - - return truncateToWidth(segments.join(sep), width); - } - - +export default function customFooter(pi: ExtensionAPI) { + const slots = createFooterSlotRegistry(pi.events); + let currentMode: PermissionMode = "safe"; + let tuiRef: { requestRender(): void } | null = null; + let footerDataRef: FooterData | null = null; + let responseEndedAt: number | null = null; + let responseAgeTimer: ReturnType | undefined; + + const clearTimer = () => { + if (responseAgeTimer) clearInterval(responseAgeTimer); + responseAgeTimer = undefined; + }; + + pi.events.on("mode:change", (data: unknown) => { + currentMode = data as PermissionMode; + tuiRef?.requestRender(); + }); + + pi.on("session_start", async (_event, ctx) => { + clearTimer(); + responseAgeTimer = setInterval(() => { + if (responseEndedAt !== null) tuiRef?.requestRender(); + }, 1000); + responseAgeTimer.unref?.(); + + ctx.ui.setFooter((_footerTui, _footerTheme, footerData) => { + footerDataRef = footerData; + const unsubscribeBranch = footerData.onBranchChange(() => tuiRef?.requestRender()); + return { + dispose() { + unsubscribeBranch(); + if (footerDataRef === footerData) footerDataRef = null; + }, + render() { return []; }, + invalidate() { tuiRef?.requestRender(); }, + }; + }); + + const setWidget = ctx.ui.setWidget.bind(ctx.ui) as ( + name: string, + content: unknown, + options?: { placement?: string }, + ) => void; + setWidget( + "custom-footer", + (widgetTui: { requestRender(): void }, theme: FooterTheme) => { + tuiRef = widgetTui; + return { + render(width: number): string[] { + const lines = [renderLine1(width, theme, ctx)]; + const statuses = footerDataRef?.getExtensionStatuses(); + if (statuses) { + const separator = theme.fg("dim", " · "); + const slotLine = packFooterStatuses(statuses, slots.priorities, width, separator); + if (slotLine) lines.push(slotLine); + } + return lines; + }, + invalidate() {}, + }; + }, + { placement: "belowEditor" }, + ); + slots.announceHost(); + }); + + pi.on("agent_start", () => { + responseEndedAt = null; + tuiRef?.requestRender(); + }); + + pi.on("agent_end", () => { + responseEndedAt = Date.now(); + tuiRef?.requestRender(); + }); + + pi.on("session_shutdown", async (_event, ctx) => { + clearTimer(); + ctx.ui.setWidget("custom-footer", undefined); + ctx.ui.setFooter(undefined); + slots.clear(); + footerDataRef = null; + tuiRef = null; + responseEndedAt = null; + }); + + function renderLine1( + width: number, + theme: FooterTheme, + ctx: { + cwd: string; + getContextUsage(): { percent: number | null; contextWindow: number } | null | undefined; + model: { provider?: string; id?: string; contextWindow?: number } | null | undefined; + }, + ): string { + const separator = theme.fg("dim", " │ "); + const separatorWidth = 3; + const pill = renderModePill(currentMode, theme); + const pillWidth = modePillWidth(currentMode); + const pathRaw = buildPathString(ctx.cwd, footerDataRef?.getGitBranch() ?? null); + + const usage = ctx.getContextUsage(); + const percent = usage?.percent ?? 0; + const contextWindow = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0; + const contextRaw = `${percent.toFixed(0)}%/${fmtTokens(contextWindow)}`; + const context = renderContextUsage(percent, contextWindow, theme); + + const provider = ctx.model?.provider || "unknown"; + const modelName = ctx.model?.id || "no-model"; + const model = renderModelInfo(modelName, provider, pi.getThinkingLevel(), theme); + const responseTimeRaw = responseEndedAt === null ? "" : formatResponseTime(responseEndedAt); + const responseTime = responseTimeRaw ? theme.fg("dim", responseTimeRaw) : ""; + + const responseTimeWidth = responseTimeRaw ? separatorWidth + visibleWidth(responseTimeRaw) : 0; + const rightBlockWidth = visibleWidth(contextRaw) + responseTimeWidth + separatorWidth + model.rawWidth; + const pathBudget = width - pillWidth - separatorWidth - rightBlockWidth - separatorWidth; + const pathDisplay = renderPath(pathRaw, pathBudget, theme); + + const segments: string[] = [pill]; + if (pathDisplay) segments.push(pathDisplay); + segments.push(context); + if (responseTime) segments.push(responseTime); + segments.push(model.text); + return truncateToWidth(segments.join(separator), width); + } } diff --git a/packages/pi-ext/extensions/custom-footer/footer-slots.ts b/packages/pi-ext/extensions/custom-footer/footer-slots.ts new file mode 100644 index 0000000..bb1e98f --- /dev/null +++ b/packages/pi-ext/extensions/custom-footer/footer-slots.ts @@ -0,0 +1,100 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +export const FOOTER_SLOT_PROTOCOL_VERSION = 1 as const; +export const FOOTER_SLOT_HOST_READY = "@iurysza/pi-ext/footer-slot/ready/v1"; +export const FOOTER_SLOT_REGISTER = "@iurysza/pi-ext/footer-slot/register/v1"; +export const FOOTER_SLOT_UNREGISTER = "@iurysza/pi-ext/footer-slot/unregister/v1"; + +export interface FooterSlotRegistration { + protocolVersion: typeof FOOTER_SLOT_PROTOCOL_VERSION; + id: string; + priority: number; +} + +interface FooterSlotUnregistration { + protocolVersion: typeof FOOTER_SLOT_PROTOCOL_VERSION; + id: string; +} + +function registrationFrom(data: unknown): FooterSlotRegistration | undefined { + if (!data || typeof data !== "object") return undefined; + const candidate = data as Partial; + if ( + candidate.protocolVersion !== FOOTER_SLOT_PROTOCOL_VERSION + || typeof candidate.id !== "string" + || candidate.id.trim() !== candidate.id + || candidate.id.length === 0 + || typeof candidate.priority !== "number" + || !Number.isFinite(candidate.priority) + ) return undefined; + return candidate as FooterSlotRegistration; +} + +function unregistrationFrom(data: unknown): FooterSlotUnregistration | undefined { + if (!data || typeof data !== "object") return undefined; + const candidate = data as Partial; + if ( + candidate.protocolVersion !== FOOTER_SLOT_PROTOCOL_VERSION + || typeof candidate.id !== "string" + || candidate.id.length === 0 + ) return undefined; + return candidate as FooterSlotUnregistration; +} + +export function createFooterSlotRegistry(events: ExtensionAPI["events"]) { + const priorities = new Map(); + const disposeRegister = events.on(FOOTER_SLOT_REGISTER, (data) => { + const registration = registrationFrom(data); + if (registration) priorities.set(registration.id, registration.priority); + }); + const disposeUnregister = events.on(FOOTER_SLOT_UNREGISTER, (data) => { + const registration = unregistrationFrom(data); + if (registration) priorities.delete(registration.id); + }); + + return { + priorities, + announceHost() { + events.emit(FOOTER_SLOT_HOST_READY, { protocolVersion: FOOTER_SLOT_PROTOCOL_VERSION }); + }, + clear() { + priorities.clear(); + }, + dispose() { + disposeRegister(); + disposeUnregister(); + priorities.clear(); + }, + }; +} + +function sanitizeStatus(value: string): string { + return value.replace(/[\r\n\t]+/g, " ").trim(); +} + +export function packFooterStatuses( + statuses: ReadonlyMap, + priorities: ReadonlyMap, + width: number, + separator: string, +): string | null { + if (width <= 0 || statuses.size === 0) return null; + const slots = [...statuses.entries()] + .map(([id, value]) => ({ id, value: sanitizeStatus(value), priority: priorities.get(id) ?? 0 })) + .filter(({ value }) => value.length > 0) + .sort((left, right) => right.priority - left.priority || left.id.localeCompare(right.id)); + if (slots.length === 0) return null; + + const prefix = " "; + const first = `${prefix}${slots[0].value}`; + if (visibleWidth(first) > width) return truncateToWidth(first, width, "…"); + + let packed = first; + for (const slot of slots.slice(1)) { + const candidate = `${packed}${separator}${slot.value}`; + if (visibleWidth(candidate) > width) break; + packed = candidate; + } + return packed; +} diff --git a/packages/pi-ext/scripts/test-tool-presentation.mjs b/packages/pi-ext/scripts/test-tool-presentation.mjs index 6ea58b0..2f66b5a 100644 --- a/packages/pi-ext/scripts/test-tool-presentation.mjs +++ b/packages/pi-ext/scripts/test-tool-presentation.mjs @@ -6,7 +6,15 @@ import { dirname, resolve } from "node:path"; const require = createRequire(import.meta.url); const packageRoot = resolve(import.meta.dirname, ".."); const output = resolve(packageRoot, ".tool-presentation-test-dist"); -const testsDir = resolve(output, "tests/tool-presentation/tidy"); +const testsRoot = resolve(output, "tests"); + +function findTests(directory) { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) return findTests(path); + return entry.name.endsWith(".test.js") ? [path] : []; + }); +} rmSync(output, { recursive: true, force: true }); try { @@ -16,10 +24,7 @@ try { "-p", resolve(packageRoot, "tests/tool-presentation/tsconfig.json"), ], { cwd: packageRoot, stdio: "inherit" }); - const tests = readdirSync(testsDir) - .filter((name) => name.endsWith(".test.js")) - .map((name) => resolve(testsDir, name)); - execFileSync(process.execPath, ["--test", ...tests], { + execFileSync(process.execPath, ["--test", ...findTests(testsRoot)], { cwd: packageRoot, stdio: "inherit", }); diff --git a/packages/pi-ext/tests/custom-footer/footer-slots.test.ts b/packages/pi-ext/tests/custom-footer/footer-slots.test.ts new file mode 100644 index 0000000..684b07d --- /dev/null +++ b/packages/pi-ext/tests/custom-footer/footer-slots.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { + FOOTER_SLOT_HOST_READY, + FOOTER_SLOT_REGISTER, + createFooterSlotRegistry, + packFooterStatuses, +} from "../../extensions/custom-footer/footer-slots.js"; + +function fakeEvents() { + const handlers = new Map void>>(); + return { + emit(channel: string, data: unknown) { + for (const handler of handlers.get(channel) ?? []) handler(data); + }, + on(channel: string, handler: (data: unknown) => void) { + const channelHandlers = handlers.get(channel) ?? new Set(); + channelHandlers.add(handler); + handlers.set(channel, channelHandlers); + return () => channelHandlers.delete(handler); + }, + } as ExtensionAPI["events"]; +} + +describe("footer slot registry", () => { + it("accepts producers loaded before or after the host", () => { + const events = fakeEvents(); + events.on(FOOTER_SLOT_HOST_READY, () => { + events.emit(FOOTER_SLOT_REGISTER, { + protocolVersion: 1, + id: "early", + priority: 200, + }); + }); + + const registry = createFooterSlotRegistry(events); + registry.announceHost(); + assert.equal(registry.priorities.get("early"), 200); + + events.emit(FOOTER_SLOT_REGISTER, { + protocolVersion: 1, + id: "late", + priority: 100, + }); + assert.equal(registry.priorities.get("late"), 100); + }); + + it("ignores malformed registrations and clears session metadata", () => { + const events = fakeEvents(); + const registry = createFooterSlotRegistry(events); + events.emit(FOOTER_SLOT_REGISTER, { protocolVersion: 2, id: "wrong", priority: 999 }); + events.emit(FOOTER_SLOT_REGISTER, { protocolVersion: 1, id: "", priority: 999 }); + events.emit(FOOTER_SLOT_REGISTER, { protocolVersion: 1, id: "nan", priority: Number.NaN }); + assert.equal(registry.priorities.size, 0); + registry.priorities.set("temporary", 1); + registry.clear(); + assert.equal(registry.priorities.size, 0); + }); +}); + +describe("packFooterStatuses", () => { + it("packs whole slots by descending priority", () => { + const statuses = new Map([ + ["legacy", "legacy"], + ["token", "tokens"], + ["cache", "cache"], + ]); + const priorities = new Map([["cache", 200], ["token", 100]]); + assert.equal(packFooterStatuses(statuses, priorities, 20, " · "), " cache · tokens"); + }); + + it("omits all lower priorities once the next slot does not fit", () => { + const statuses = new Map([ + ["high", "high"], + ["middle", "middle-is-wide"], + ["low", "x"], + ]); + const priorities = new Map([["high", 200], ["middle", 100], ["low", 0]]); + assert.equal(packFooterStatuses(statuses, priorities, 12, " · "), " high"); + }); + + it("truncates only the highest-priority slot and respects ANSI width", () => { + const statuses = new Map([ + ["high", "\u001b[31mhighest-priority-value\u001b[39m"], + ["low", "low"], + ]); + const priorities = new Map([["high", 200], ["low", 100]]); + const packed = packFooterStatuses(statuses, priorities, 9, " · "); + assert.ok(packed); + assert.equal(visibleWidth(packed), 9); + assert.ok(!packed.includes("low")); + }); + + it("sanitizes multiline legacy statuses", () => { + const packed = packFooterStatuses(new Map([["legacy", "one\ntwo\tthree"]]), new Map(), 40, " · "); + assert.equal(packed, " one two three"); + }); +}); diff --git a/packages/pi-ext/tests/tool-presentation/tsconfig.json b/packages/pi-ext/tests/tool-presentation/tsconfig.json index 0b4dd88..0d55e7b 100644 --- a/packages/pi-ext/tests/tool-presentation/tsconfig.json +++ b/packages/pi-ext/tests/tool-presentation/tsconfig.json @@ -14,7 +14,9 @@ "types": ["node"] }, "include": [ + "../../extensions/custom-footer/**/*.ts", "../../extensions/tool-presentation/**/*.ts", + "../custom-footer/**/*.test.ts", "./tidy/**/*.test.ts" ] } diff --git a/packages/pi-token-tank/package.json b/packages/pi-token-tank/package.json index 386a3df..2703e1f 100644 --- a/packages/pi-token-tank/package.json +++ b/packages/pi-token-tank/package.json @@ -1,6 +1,6 @@ { "name": "@iurysza/pi-token-tank", - "version": "0.6.2", + "version": "0.6.3", "description": "Token Tank — provider-aware subscription quota status for Pi", "license": "MIT", "author": "Iury Souza", diff --git a/packages/pi-token-tank/src/footer-slot.ts b/packages/pi-token-tank/src/footer-slot.ts new file mode 100644 index 0000000..a0eca03 --- /dev/null +++ b/packages/pi-token-tank/src/footer-slot.ts @@ -0,0 +1,27 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +const PROTOCOL_VERSION = 1 as const; +const HOST_READY = "@iurysza/pi-ext/footer-slot/ready/v1"; +const REGISTER = "@iurysza/pi-ext/footer-slot/register/v1"; +const UNREGISTER = "@iurysza/pi-ext/footer-slot/unregister/v1"; + +export function createFooterSlotRegistration( + events: ExtensionAPI["events"], + id: string, + priority: number, +) { + const payload = { protocolVersion: PROTOCOL_VERSION, id, priority }; + const register = () => events.emit(REGISTER, payload); + const disposeReady = events.on(HOST_READY, (data) => { + if ((data as { protocolVersion?: unknown } | undefined)?.protocolVersion === PROTOCOL_VERSION) register(); + }); + register(); + + return { + register, + dispose() { + events.emit(UNREGISTER, { protocolVersion: PROTOCOL_VERSION, id }); + disposeReady(); + }, + }; +} diff --git a/packages/pi-token-tank/src/index.ts b/packages/pi-token-tank/src/index.ts index ead9449..72cbcc1 100644 --- a/packages/pi-token-tank/src/index.ts +++ b/packages/pi-token-tank/src/index.ts @@ -7,6 +7,7 @@ import { truncateToWidth } from "@earendil-works/pi-tui"; import { createCredentialSource, type CredentialSourceLike } from "./auth.js"; import { captureCursorSessionToken, fetchCursorQuotaWithToken } from "./cursor.js"; import { formatFooter, formatWidget } from "./format.js"; +import { createFooterSlotRegistration } from "./footer-slot.js"; import { loadFooterMode, saveFooterMode, type FooterMode } from "./preferences.js"; import { createCursorProvider, @@ -119,6 +120,7 @@ export function createTokenTank( let credentials = credentialSourceOverride; let widgetVisible = false; let footerMode: FooterMode = "minimal"; + const footerSlot = createFooterSlotRegistration(pi.events, STATUS_KEY, 100); function sameRegistry(left: readonly QuotaProvider[], right: readonly QuotaProvider[]): boolean { return left.length === right.length && left.every((provider, index) => provider.id === right[index]?.id); @@ -150,7 +152,7 @@ export function createTokenTank( return coordinator; } - function updateFooter(ctx: ExtensionContext) { + function updateStatusSlot(ctx: ExtensionContext) { const activeCoordinator = getCoordinator(ctx); const provider = findProviderForModel(ctx.model, runtimeRegistry); if (!provider) { @@ -184,7 +186,7 @@ export function createTokenTank( async function refreshAndRender(ctx: ExtensionContext, force: boolean) { const activeCoordinator = getCoordinator(ctx); const provider = findProviderForModel(ctx.model, runtimeRegistry); - updateFooter(ctx); + updateStatusSlot(ctx); if (provider) await activeCoordinator.refresh(provider.id, force); if (widgetVisible && pendingProviderIds.length > 0) { const addedProviderIds = pendingProviderIds; @@ -193,11 +195,12 @@ export function createTokenTank( .filter((providerId) => providerId !== provider?.id) .map((providerId) => activeCoordinator.refresh(providerId, force))); } - updateFooter(ctx); + updateStatusSlot(ctx); if (widgetVisible) updateWidget(ctx); } pi.on("session_start", async (_event, ctx) => { + footerSlot.register(); footerMode = await loadFooterMode(preferenceFile); await refreshAndRender(ctx, true); }); @@ -212,6 +215,7 @@ export function createTokenTank( coordinator = undefined; runtimeRegistry = registry; pendingProviderIds = []; + footerSlot.dispose(); }); pi.registerCommand("token-tank", { @@ -223,7 +227,7 @@ export function createTokenTank( const argument = args.trim(); if (argument === "minimal" || argument === "full") { footerMode = argument; - updateFooter(ctx); + updateStatusSlot(ctx); await saveFooterMode(footerMode, preferenceFile); return; } @@ -240,7 +244,7 @@ export function createTokenTank( await getCoordinator(ctx).refreshAll(true); pendingProviderIds = []; } - updateFooter(ctx); + updateStatusSlot(ctx); updateWidget(ctx); }, }); diff --git a/packages/pi-token-tank/tests/index.test.ts b/packages/pi-token-tank/tests/index.test.ts index 838d10b..1b40a20 100644 --- a/packages/pi-token-tank/tests/index.test.ts +++ b/packages/pi-token-tank/tests/index.test.ts @@ -40,6 +40,8 @@ function fakeAPI(provider = "openai-codex", registeredProviderIds: string[] = [] const widgetKinds: Record = {}; const widgetRenderers: Record string[]) | undefined> = {}; const commands: Record Promise }> = {}; + const busHandlers = new Map void>>(); + const busEvents: Array<{ channel: string; data: unknown }> = []; const ctx: ExtensionContext = { mode: "tui", @@ -77,6 +79,18 @@ function fakeAPI(provider = "openai-codex", registeredProviderIds: string[] = [] return { api: { + events: { + emit(channel: string, data: unknown) { + busEvents.push({ channel, data }); + for (const handler of busHandlers.get(channel) ?? []) handler(data); + }, + on(channel: string, handler: (data: unknown) => void) { + const handlersForChannel = busHandlers.get(channel) ?? new Set(); + handlersForChannel.add(handler); + busHandlers.set(channel, handlersForChannel); + return () => handlersForChannel.delete(handler); + }, + }, on: (event: string, handler: (event: unknown, ctx: unknown) => Promise) => { handlers[event] = handlers[event] ?? []; handlers[event]!.push(handler); @@ -91,6 +105,7 @@ function fakeAPI(provider = "openai-codex", registeredProviderIds: string[] = [] widgetKinds, widgetRenderers, commands, + busEvents, ctx, async fire(event: string, payload: unknown) { for (const h of handlers[event] ?? []) { @@ -350,6 +365,10 @@ describe("createTokenTank", () => { fakeAPI(); const registered: string[] = []; const pi = { + events: { + emit: () => {}, + on: () => () => {}, + }, on: () => {}, registerCommand: (name: string) => { registered.push(name); @@ -360,6 +379,27 @@ describe("createTokenTank", () => { assert.ok(!registered.includes("usage")); }); + it( + "registers footer slot metadata and unregisters on shutdown", + withMockFetch(async () => { + const f = fakeAPI(); + createTokenTank(f.api, fakeCredentials()); + const registrations = () => f.busEvents.filter(({ channel }) => channel.endsWith("/register/v1")); + assert.deepEqual(registrations().at(-1)?.data, { + protocolVersion: 1, + id: "pi-token-tank", + priority: 100, + }); + + f.api.events.emit("@iurysza/pi-ext/footer-slot/ready/v1", { protocolVersion: 1 }); + assert.equal(registrations().length, 2); + await f.fire("session_shutdown", {}); + assert.ok(f.busEvents.some(({ channel, data }) => + channel.endsWith("/unregister/v1") + && (data as { id?: string }).id === "pi-token-tank")); + }), + ); + it( "sets footer on session_start", withMockFetch(async () => {