import { fileURLToPath } from "node:url"; import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent"; import { visibleWidth } from "@earendil-works/pi-tui"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createPiSession, type PiSession, } from "../../../test/fixture/create-pi-session.ts"; import { __test } from "../index.ts"; import type { ProducerBridge } from "./producer.ts"; const footerPath = fileURLToPath(new URL("../index.ts", import.meta.url)); const producerPath = fileURLToPath(new URL("./producer.ts", import.meta.url)); interface UiCall { args: unknown[]; } /** * Records `setFooter` factories by capturing each `setFooter` call, and stands * in for the interactive UI so `hasUI` is true once mounted. */ function createRecordingUi() { const setFooterCalls: UiCall[] = []; const ui = { setFooter: (factory: unknown) => { setFooterCalls.push({ args: [factory] }); }, notify: () => {}, }; return { ui: ui as unknown as ExtensionUIContext, setFooterCalls }; } function setBridge(bridge: ProducerBridge): void { (globalThis as Record)["__footerProducerBridge"] = bridge; } describe("footer contract", () => { let session: PiSession | undefined; afterEach(async () => { vi.restoreAllMocks(); (globalThis as Record)["__footerProducerBridge"] = undefined; await session?.dispose(); session = undefined; }); async function mountTui( active: PiSession, ui: ExtensionUIContext, ): Promise { const runner = active.session.extensionRunner; runner.setUIContext(ui, "tui"); await runner.emit({ type: "session_start", reason: "startup" }); } it("loads in real Pi extension runtime", async () => { session = await createPiSession({ extensionPaths: [footerPath] }); expect(session.extensions).toHaveLength(1); expect(session.extensions[0]!.path).toBe(footerPath); }); it("acknowledges and retains pre-mount updates without polling", async () => { let accepted = 0; let offer: ((id: string) => void) | undefined; setBridge({ onLoad: (emit) => { offer = (id) => emit({ id, text: "ready", zone: "llm", accept: () => accepted++, }); offer("early"); }, }); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); session = await createPiSession({ extensionPaths: [footerPath, producerPath], }); const { ui, setFooterCalls } = createRecordingUi(); expect(accepted).toBe(1); expect(setFooterCalls).toHaveLength(0); const runner = session.session.extensionRunner; runner.setUIContext(ui, "rpc"); await runner.emit({ type: "session_start", reason: "startup" }); expect(setFooterCalls).toHaveLength(0); offer?.("rpc"); expect(accepted).toBe(1); await mountTui(session, ui); expect(accepted).toBe(1); expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 50)).toBe( false, ); setTimeoutSpy.mockRestore(); const factory = setFooterCalls.at(-1)?.args[0] as | (( tui: { requestRender(): void }, theme: { fg(style: string, text: string): string }, data: { onBranchChange(callback: () => void): () => void; getExtensionStatuses(): ReadonlyMap; }, ) => { render(width: number): string[] }) | undefined; const component = factory?.( { requestRender() {} }, { fg: (_style, text) => text }, { onBranchChange: () => () => {}, getExtensionStatuses: () => new Map(), }, ); expect(component?.render(80).join("\n")).toContain("ready"); }); it("isolates acknowledgements from a nested non-TUI runtime", async () => { let parentAccepted = 0; setBridge({ onLoad: (emit) => { emit({ id: "parent", text: "ready", zone: "llm", accept: () => parentAccepted++, }); }, }); session = await createPiSession({ extensionPaths: [footerPath, producerPath], }); const { ui } = createRecordingUi(); await mountTui(session, ui); expect(parentAccepted).toBe(1); const nested = await createPiSession({ extensionPaths: [footerPath] }); try { const runner = nested.session.extensionRunner; const { ui: nestedUi } = createRecordingUi(); runner.setUIContext(nestedUi, "rpc"); await runner.emit({ type: "session_start", reason: "startup" }); await runner.emit({ type: "session_shutdown", reason: "quit" }); expect(parentAccepted).toBe(1); } finally { await nested.dispose(); } }); it("keeps valid segments when a producer emits malformed data", async () => { const bridge: ProducerBridge = {}; setBridge(bridge); session = await createPiSession({ extensionPaths: [footerPath, producerPath], }); const emitSegment = bridge.emit; const { ui, setFooterCalls } = createRecordingUi(); await mountTui(session, ui); const factory = setFooterCalls.at(-1)?.args[0] as | (( tui: { requestRender(): void }, theme: { fg(style: string, text: string): string }, data: { onBranchChange(callback: () => void): () => void; getExtensionStatuses(): ReadonlyMap; }, ) => { render(width: number): string[] }) | undefined; const component = factory?.( { requestRender() {} }, { fg: (style, text) => { if (style === "not-a-theme-color") throw new Error("bad color"); return text; }, }, { onBranchChange: () => () => {}, getExtensionStatuses: () => new Map(), }, ); let accepted = 0; emitSegment?.({ id: "good", text: "healthy", zone: "llm", accept: () => accepted++, }); emitSegment?.({ id: "good", text: 42, zone: "llm", accept: () => accepted++, }); expect(accepted).toBe(1); emitSegment?.({ id: "good", text: "poison", zone: "llm", color: "not-a-theme-color", accept: () => accepted++, }); expect(accepted).toBe(1); expect(component?.render(80).join("\n")).toContain("healthy"); expect(component?.render(80).join("\n")).not.toContain("poison"); }); it("keeps later LLM components visible as earlier values grow", async () => { const bridge: ProducerBridge = {}; setBridge(bridge); session = await createPiSession({ extensionPaths: [footerPath, producerPath], }); const emitSegment = bridge.emit; const { ui, setFooterCalls } = createRecordingUi(); await mountTui(session, ui); const factory = setFooterCalls.at(-1)?.args[0] as | (( tui: { requestRender(): void }, theme: { fg(style: string, text: string): string }, data: { onBranchChange(callback: () => void): () => void; getExtensionStatuses(): ReadonlyMap; }, ) => { render(width: number): string[]; dispose(): void; }) | undefined; expect(factory).toBeTypeOf("function"); const component = factory?.( { requestRender() {} }, { fg: (_style, text) => text }, { onBranchChange: () => () => {}, getExtensionStatuses: () => new Map(), }, ); expect(component).toBeDefined(); emitSegment?.({ id: "model", text: "sol:medium", zone: "llm", order: 0 }); emitSegment?.({ id: "tokens", text: "↑0", zone: "llm", order: 2 }); emitSegment?.({ id: "tps", text: "50.0/s", zone: "llm", order: 4 }); const initial = component?.render(32) ?? []; expect(initial).toHaveLength(2); expect(initial.join("\n")).toContain("50.0/s"); emitSegment?.({ id: "tokens", text: "↑12k ↓3.4k", zone: "llm", order: 2, }); const grown = component?.render(32) ?? []; expect(grown).toHaveLength(3); expect(grown.join("\n")).toContain("50.0/s"); expect(grown.every((line) => visibleWidth(line) <= 32)).toBe(true); emitSegment?.({ id: "tokens", text: "↑0", zone: "llm", order: 2 }); const shrunk = component?.render(32) ?? []; expect(shrunk).toHaveLength(2); expect(shrunk.join("\n")).toContain("50.0/s"); emitSegment?.({ id: "provider", text: "provider", zone: "llm", group: "model-route", order: -0.5, }); emitSegment?.({ id: "model", text: "sol:medium", zone: "llm", group: "model-route", gap: 2, order: 0, }); expect(component?.render(80).join("\n")).toContain("provider sol:medium"); component?.dispose(); }); it.each([ [true, "tui", true], [true, "rpc", false], [false, "json", false], [false, "print", false], [false, "tui", false], ] as const)( "requires UI and TUI mode when hasUI=%s and mode=%s", (hasUI, mode, expected) => { expect(__test.canRenderFooter({ hasUI, mode })).toBe(expected); }, ); it("keeps equal-priority order stable after removal and reinsertion", () => { const quota = { id: "quota:anthropic", text: "quota", zone: "llm", order: 4, } as const; const tps = { id: "tps", text: "tps", zone: "llm", order: 4, } as const; const segments = new Map([ [tps.id, tps], [quota.id, quota], ]); segments.delete(tps.id); segments.set(tps.id, tps); expect([...segments.values()].sort(__test.compareSegments)).toEqual([ quota, tps, ]); }); it("renders nothing and schedules no retries when hidden", async () => { const bridge: ProducerBridge = {}; setBridge(bridge); session = await createPiSession({ extensionPaths: [footerPath, producerPath], }); const emitSegment = bridge.emit; const { ui, setFooterCalls } = createRecordingUi(); await mountTui(session, ui); const runner = session.session.extensionRunner; await session.extensions[0]?.commands .get("footer") ?.handler("", runner.createCommandContext()); const factory = setFooterCalls.at(-1)?.args[0] as | (( tui: { requestRender(force?: boolean): void }, theme: { fg(style: string, text: string): string }, data: { getGitBranch(): string | null; onBranchChange(callback: () => void): () => void; getExtensionStatuses(): ReadonlyMap; }, ) => { render(width: number): string[]; }) | undefined; expect( factory?.( { requestRender() {} }, { fg: (_style, text) => text }, { getGitBranch: () => null, onBranchChange: () => () => {}, getExtensionStatuses: () => new Map(), }, ).render(80), ).toEqual([]); const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); emitSegment?.({ id: "tps", text: "50/s", zone: "llm" }); expect(setTimeoutSpy).not.toHaveBeenCalled(); setTimeoutSpy.mockRestore(); }); it("re-publishes Pi branch changes as footer:branch-change", async () => { const bridge: ProducerBridge = {}; setBridge(bridge); session = await createPiSession({ extensionPaths: [footerPath, producerPath], }); const received: unknown[] = []; bridge.listen?.("footer:branch-change", (data) => received.push(data)); const { ui, setFooterCalls } = createRecordingUi(); await mountTui(session, ui); type Factory = ( tui: { requestRender(force?: boolean): void }, theme: { fg(style: string, text: string): string }, data: { getGitBranch(): string | null; onBranchChange(callback: () => void): () => void; getExtensionStatuses(): ReadonlyMap; }, ) => { dispose(): void }; let branch: string | null = "main"; let unsubscribed = 0; const mountWithBranchData = () => { let notify: (() => void) | undefined; const factory = setFooterCalls.at(-1)?.args[0] as Factory | undefined; const component = factory?.( { requestRender() {} }, { fg: (_style, text) => text }, { getGitBranch: () => branch, onBranchChange: (callback) => { notify = callback; return () => unsubscribed++; }, getExtensionStatuses: () => new Map(), }, ); return { component, notify: () => notify?.() }; }; const visible = mountWithBranchData(); visible.notify(); expect(received).toEqual([{ branch: "main" }]); branch = null; visible.notify(); expect(received).toEqual([{ branch: "main" }, { branch: null }]); visible.component?.dispose(); expect(unsubscribed).toBe(1); // Hidden footer still forwards branch changes. const runner = session.session.extensionRunner; await session.extensions[0]?.commands .get("footer") ?.handler("", runner.createCommandContext()); branch = "feature"; const hiddenFooter = mountWithBranchData(); hiddenFooter.notify(); expect(received.at(-1)).toEqual({ branch: "feature" }); hiddenFooter.component?.dispose(); expect(unsubscribed).toBe(2); }); it("right-aligns statuses beside the workspace row", () => { expect(__test.alignLeftAndRight("workspace", "busy", 16)).toBe( "workspace busy", ); }); it("preserves the workspace row when statuses exceed the remaining space", () => { const line = __test.alignLeftAndRight("workspace", "very long status", 12); expect(visibleWidth(line)).toBe(12); expect(line.startsWith("workspace ")).toBe(true); }); it("uses the first row when its first group is oversized", () => { const [first, second] = __test.flowLines( ["oversized-model-route", "tokens"], " | ", 8, ); expect(first).not.toBe(""); expect(second).toBe("tokens"); }); it("keeps adjacent members of a named segment group atomic", () => { const model = { id: "model", text: "sol:medium", zone: "llm" } as const; const fast = { id: "fast", text: "", zone: "llm", group: "model-route", } as const; const angel = { id: "angel", text: "astra:max", zone: "llm", group: "model-route", } as const; const tokens = { id: "tokens", text: "1k", zone: "llm" } as const; expect( __test.groupSegments([ { ...model, group: "model-route" }, fast, angel, tokens, ]), ).toEqual([[{ ...model, group: "model-route" }, fast, angel], [tokens]]); }); });