feat(codex): unify overview and usage inspectors #11

Merged
eleith merged 1 commits from feat/codex-inspectors into main 2026-11-05 14:32:00 +00:00
14 changed files with 1440 additions and 401 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@eleith-pi/codex": patch
---
Show the Codex overview and account usage in a consistent, titled terminal inspector with native borders and keybinding-aware footers. Keep loading, results, and errors in one dismissible overlay without transcript notifications, support scrolling for longer reports, and preserve textual feedback outside the terminal UI.
+2 -2
View File
@@ -17,14 +17,14 @@ pi install npm:@eleith-pi/codex
## use
- `/eleith:codex` shows the local context and fast-mode dashboard, including compaction and cache status.
- `/eleith:codex` opens the local context and fast-mode inspector, including compaction and cache status. Bare `context` and `fast` open the same overview.
- `/eleith:codex context on` selects the verified local window when larger.
- `/eleith:codex context off` returns to the catalog baseline, confirming reductions.
- `/eleith:codex context toggle` switches the saved context preference.
- `/eleith:codex fast on` checks catalog eligibility and requests priority processing after confirmation of higher allowance use.
- `/eleith:codex fast off` requests standard processing.
- `/eleith:codex fast toggle` switches the saved fast preference.
- `/eleith:codex usage` fetches account allowance and reset information. in the terminal, the cancel key (Escape by default) aborts the request.
- `/eleith:codex usage` opens account allowance and reset information in the same inspector, showing a spinner while fetching.
## config
+2 -1
View File
@@ -31,7 +31,8 @@
},
"peerDependencies": {
"@earendil-works/pi-ai": "*",
"@earendil-works/pi-coding-agent": "*"
"@earendil-works/pi-coding-agent": "*",
"@earendil-works/pi-tui": "*"
},
"pi": {
"extensions": [
@@ -2,23 +2,39 @@ import { vi } from "vitest";
import type {
BorderedLoader,
ExtensionUIContext,
KeybindingsManager,
KeybindingsManager as PiKeybindingsManager,
Theme,
} from "@earendil-works/pi-coding-agent";
import { KeybindingsManager, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
type Tui = ConstructorParameters<typeof BorderedLoader>[0];
type UIComponent = Awaited<ReturnType<Parameters<ExtensionUIContext["custom"]>[0]>>;
type CustomFactory<T> = (
tui: Tui,
theme: Theme,
kb: KeybindingsManager,
kb: PiKeybindingsManager,
done: (result: T) => void,
) => UIComponent | Promise<UIComponent>;
// The public custom-interaction lifecycle, without starting a terminal. Model Pi 1.3's
// shared editor restoration (not 1.4's dialog stack), so unsafe non-overlay closes
// really can steal focus from a newer interaction. Overlay removal owns only its entry.
export function customInteractionUI() {
export function customInteractionUI(
options: {
rows?: number;
columns?: number;
keybindings?: KeybindingsManager;
theme?: Theme;
} = {},
) {
const terminal = { rows: options.rows ?? 40, columns: options.columns ?? 100 };
const keybindings = options.keybindings ?? new KeybindingsManager(TUI_KEYBINDINGS);
const theme =
options.theme ??
({
fg: (_color: string, text: string) => text,
bold: (text: string) => text,
} as Theme);
const mainEditor: UIComponent = { render: () => ["editor"], invalidate() {} };
let editorComponent = mainEditor;
let focusedComponent = mainEditor;
@@ -27,12 +43,21 @@ export function customInteractionUI() {
const modals: {
component?: UIComponent;
lines: string[];
renders: string[][];
closed: boolean;
adopted: boolean;
close: ReturnType<typeof vi.fn<(result?: unknown) => void>>;
requestRender: ReturnType<typeof vi.fn<() => void>>;
}[] = [];
requestRender.mockImplementation(() => {
for (const modal of modals) {
if (!modal.adopted || modal.closed || !modal.component) continue;
modal.lines = modal.component.render(terminal.columns);
modal.renders.push([...modal.lines]);
}
});
const restoreEditor = () => {
editorComponent = mainEditor;
focusedComponent = mainEditor;
@@ -58,6 +83,7 @@ export function customInteractionUI() {
const modal = {
component: undefined as UIComponent | undefined,
lines: [] as string[],
renders: [] as string[][],
closed: false,
adopted: false,
close: vi.fn(),
@@ -75,9 +101,9 @@ export function customInteractionUI() {
modals.push(modal);
Promise.resolve(
factory(
{ requestRender: modal.requestRender } as unknown as Tui,
{ fg: (_color: string, text: string) => text } as Theme,
{} as KeybindingsManager,
{ requestRender: modal.requestRender, terminal } as unknown as Tui,
theme,
keybindings as PiKeybindingsManager,
modal.close,
),
)
@@ -94,7 +120,6 @@ export function customInteractionUI() {
editorComponent = component;
}
focusedComponent = component;
modal.lines = component.render(80);
requestRender();
})
.catch((error: unknown) => {
@@ -109,6 +134,8 @@ export function customInteractionUI() {
// Vitest erases the generic call signature; the implementation preserves it.
custom: custom as ExtensionUIContext["custom"] & typeof custom,
modals,
terminal,
keybindings,
requestRender,
mainEditor,
get editorComponent() {
+2 -1
View File
@@ -727,7 +727,8 @@ async function openRealExtensionSession(
const notify = vi.fn();
const errors: unknown[] = [];
await session.bindExtensions({
mode: "tui",
// Wire tests use RPC's textual inspection fallback; usage-sdk covers terminal overlays.
mode: "rpc",
uiContext: { notify, confirm } as unknown as ExtensionUIContext,
onError: (error) => errors.push(error),
});
+131 -6
View File
@@ -1,10 +1,15 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import {
initTheme,
type ExtensionAPI,
type ExtensionCommandContext,
} from "@earendil-works/pi-coding-agent";
import type { Api, Model } from "@earendil-works/pi-ai";
import { afterAll, expect, it, vi } from "vitest";
import { afterAll, afterEach, expect, it, vi } from "vitest";
import codex from "./index.ts";
import { fetchCodexModelCatalog, fetchCodexUsage, readCodexAuthScope } from "./client.ts";
import { customInteractionUI } from "./custom-interaction.test-helper.ts";
vi.mock("./client.ts", async (importOriginal) => ({
...(await importOriginal<typeof import("./client.ts")>()),
@@ -29,7 +34,13 @@ afterAll(() => {
rmSync(directory, { recursive: true, force: true });
});
async function load(prefix = "eleith") {
initTheme("dark", false);
const surfaces: ReturnType<typeof customInteractionUI>[] = [];
afterEach(() => {
for (const surface of surfaces.splice(0)) surface.dispose();
});
async function load(prefix = "eleith", mode: ExtensionCommandContext["mode"] = "print") {
vi.mocked(fetchCodexUsage).mockReset();
vi.mocked(fetchCodexModelCatalog).mockReset();
vi.mocked(readCodexAuthScope).mockReset();
@@ -55,14 +66,16 @@ async function load(prefix = "eleith") {
appendEntry: vi.fn(),
};
const notify = vi.fn();
const surface = customInteractionUI();
surfaces.push(surface);
const ctx = {
mode: "print",
mode,
model: undefined,
hasUI: false,
hasUI: mode === "tui" || mode === "rpc",
isIdle: () => true,
getContextUsage: () => undefined,
modelRegistry: {},
ui: { notify, confirm: vi.fn(async () => false) },
ui: { notify, confirm: vi.fn(async () => false), custom: surface.custom },
sessionManager: { getBranch: () => [], getSessionId: () => "session", getLeafId: () => null },
} as unknown as ExtensionCommandContext;
await codex(pi as unknown as ExtensionAPI);
@@ -70,6 +83,7 @@ async function load(prefix = "eleith") {
pi,
ctx,
notify,
surface,
async emit(type: string) {
for (const handler of listeners.get(type) ?? []) await handler({ type }, ctx);
},
@@ -287,3 +301,114 @@ it("fails invalid prefix settings before registering any capabilities", async ()
expect(on).not.toHaveBeenCalled();
expect(registerCommand).not.toHaveBeenCalled();
});
it("routes every TUI read-only alias to the shared overview inspector, not notifications", async () => {
const context = await load("personal", "tui");
await context.emit("session_start");
for (const [index, input] of ["", "context", "fast"].entries()) {
let settled = false;
const request = Promise.resolve(context.command().handler(input, context.ctx)).then(() => {
settled = true;
});
await flush();
const modal = context.surface.modals[index];
expect(modal.lines.join("\n")).toContain("Codex · Overview");
expect(modal.lines.join("\n")).toContain("No model selected.");
expect(modal.closed).toBe(false);
expect(settled).toBe(false);
modal.component!.handleInput!(index === 1 ? "\u001b" : "\r");
await request;
expect(modal.close).toHaveBeenCalledOnce();
}
expect(context.surface.custom).toHaveBeenCalledTimes(3);
expect(context.notify).not.toHaveBeenCalled();
expect(fetchCodexUsage).not.toHaveBeenCalled();
expect(fetchCodexModelCatalog).not.toHaveBeenCalled();
expect(readCodexAuthScope).not.toHaveBeenCalled();
expect(context.pi.setModel).not.toHaveBeenCalled();
expect(context.pi.appendEntry).not.toHaveBeenCalled();
await context.emit("session_shutdown");
});
it("shares TUI inspector ownership between dashboard aliases and account usage", async () => {
const context = await load("personal", "tui");
let finish!: (data: unknown) => void;
vi.mocked(fetchCodexUsage).mockReturnValueOnce(
new Promise((resolve) => {
finish = resolve;
}),
);
await context.emit("session_start");
const usage = context.command().handler("usage", context.ctx);
await flush();
const signal = vi.mocked(fetchCodexUsage).mock.calls[0][1]!;
const dashboard = context.command().handler("fast", context.ctx);
await usage; // Cancels an uncooperative fetch without waiting for its response.
await flush();
expect(signal.aborted).toBe(true);
expect(context.surface.modals[0].closed).toBe(true);
expect(context.surface.modals[1].lines.join("\n")).toContain("Codex · Overview");
vi.mocked(fetchCodexUsage).mockResolvedValueOnce({ plan_type: "plus" });
const nextUsage = context.command().handler("usage", context.ctx);
await dashboard;
await flush();
const modal = context.surface.modals[2];
expect(context.surface.modals[1].closed).toBe(true);
expect(modal.lines.join("\n")).toContain("Codex · Usage");
expect(modal.lines.join("\n")).toContain("Plan: plus");
expect(modal.closed).toBe(false);
expect(context.surface.focusedComponent).toBe(modal.component);
finish({ plan_type: "stale-plan" });
await flush();
expect(modal.lines.join("\n")).not.toContain("stale-plan");
modal.component!.handleInput!("\r");
await nextUsage;
expect(context.notify).not.toHaveBeenCalled();
expect(context.pi.appendEntry).not.toHaveBeenCalled();
expect(context.pi.setModel).not.toHaveBeenCalled();
await context.emit("session_shutdown");
});
it.each(["model_select", "session_tree", "session_shutdown"])(
"%s cancels a ready TUI overview as well as usage inspectors",
async (event) => {
const context = await load("eleith", "tui");
await context.emit("session_start");
const request = context.command().handler("context", context.ctx);
await flush();
const modal = context.surface.modals[0];
expect(modal.closed).toBe(false);
await context.emit(event);
await request;
expect(modal.closed).toBe(true);
expect(modal.close).toHaveBeenCalledOnce();
expect(context.surface.focusedComponent).toBe(context.surface.mainEditor);
expect(context.notify).not.toHaveBeenCalled();
if (event !== "session_shutdown") await context.emit("session_shutdown");
},
);
it("keeps TUI mutation feedback and invalid-argument notifications outside inspectors", async () => {
const context = await load("eleith", "tui");
await context.emit("session_start");
await context.command().handler("context on", context.ctx);
expect(context.notify).toHaveBeenCalled();
context.notify.mockClear();
await context.command().handler("fast on", context.ctx);
expect(context.notify).toHaveBeenCalled();
context.notify.mockClear();
await context.command().handler("usage extra", context.ctx);
expect(context.notify).toHaveBeenCalledExactlyOnceWith(
"Usage: /eleith:codex [context|fast|usage]",
"warning",
);
expect(context.surface.custom).not.toHaveBeenCalled();
expect(fetchCodexUsage).not.toHaveBeenCalled();
expect(fetchCodexModelCatalog).not.toHaveBeenCalled();
expect(context.pi.appendEntry).not.toHaveBeenCalled();
await context.emit("session_shutdown");
});
async function flush() {
for (let i = 0; i < 20; i++) await Promise.resolve();
}
+10 -5
View File
@@ -6,12 +6,14 @@ import { UsageController } from "./usage-controller.ts";
import { FastController } from "./fast.ts";
import { CODEX_ACTIONS, completeCodexArguments } from "./commands.ts";
import { formatCodexDashboard } from "./dashboard.ts";
import { InspectorController } from "./inspector.ts";
export default async function codex(pi: ExtensionAPI): Promise<void> {
const settings = await getSettings();
const commandName = `${settings.commandPrefix}:codex`;
const controller = new ContextController(pi, commandName);
const usage = new UsageController();
const inspector = new InspectorController();
const usage = new UsageController(inspector);
const fast = new FastController(pi, commandName);
let disposed = false;
@@ -28,10 +30,12 @@ export default async function codex(pi: ExtensionAPI): Promise<void> {
if (showStatus) {
const context = controller.getStatus(ctx);
const fastStatus = fast.getStatus(ctx);
ctx.ui.notify(formatCodexDashboard(context, fastStatus, commandName), "info");
return;
return inspector.show(ctx, "Overview", {
message: formatCodexDashboard(context, fastStatus, commandName),
});
}
if (action === "usage") return usage.report(ctx);
inspector.cancel();
if (action === "context") return controller.handle(argument ?? "", ctx);
if (action === "fast") return fast.handle(argument ?? "", ctx);
},
@@ -42,12 +46,12 @@ export default async function codex(pi: ExtensionAPI): Promise<void> {
return controller.restore(ctx);
});
pi.on("session_tree", (_event, ctx) => {
usage.cancel();
inspector.cancel();
fast.restore(ctx);
return controller.restore(ctx);
});
pi.on("model_select", (_event, ctx) => {
usage.cancel();
inspector.cancel();
fast.invalidate();
return controller.modelSelected(ctx);
});
@@ -56,6 +60,7 @@ export default async function codex(pi: ExtensionAPI): Promise<void> {
fast.dispose();
try {
usage.dispose();
inspector.dispose();
} finally {
controller.shutdown(ctx);
}
+334
View File
@@ -0,0 +1,334 @@
import { stripVTControlCharacters } from "node:util";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DynamicBorder,
type KeybindingsManager as AgentKeybindingsManager,
Theme,
} from "@earendil-works/pi-coding-agent";
import {
Container,
KeybindingsManager,
TUI_KEYBINDINGS,
Loader,
ScrollView,
Text,
visibleWidth,
type TUI,
} from "@earendil-works/pi-tui";
import { CodexInspectorView, type InspectorContent } from "./inspector-view.ts";
const views: CodexInspectorView[] = [];
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
for (const view of views.splice(0)) view.dispose();
vi.restoreAllMocks();
vi.useRealTimers();
});
// Real Theme with only the semantic roles exercised by this component.
function makeTheme(color = 39) {
return new Theme(
{
accent: color,
border: color,
text: color,
muted: color,
dim: color,
warning: color === 39 ? 214 : 220,
error: color === 39 ? 196 : 160,
thinkingXhigh: color,
} as ConstructorParameters<typeof Theme>[0],
{ selectedBg: 0 } as ConstructorParameters<typeof Theme>[1],
"truecolor",
);
}
function setup(
initial: InspectorContent | { loading: string } = { message: "Account allowance" },
rows = 24,
) {
const terminal = { rows, columns: 120 };
const requestRender = vi.fn();
const tui = { terminal, requestRender } as unknown as TUI;
const theme = makeTheme();
// Coding-agent publicly exports its manager as a type only. The native TUI
// manager provides the same selection actions without private app internals.
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS);
const onClose = vi.fn();
const view = new CodexInspectorView(
tui,
theme,
keybindings as unknown as AgentKeybindingsManager,
"Usage",
onClose,
initial,
);
views.push(view);
return { view, terminal, requestRender, theme, keybindings, onClose };
}
const plain = (lines: string[]) => lines.map((line) => stripVTControlCharacters(line).trimEnd());
const message = Array.from(
{ length: 40 },
(_, index) => `line-${String(index).padStart(2, "0")}`,
).join("\n");
const body = (view: CodexInspectorView, width = 100) => plain(view.render(width)).slice(2, -2);
it("composes real native borders, text, container and ScrollView without a second box", () => {
const borderRender = vi.spyOn(DynamicBorder.prototype, "render");
const textRender = vi.spyOn(Text.prototype, "render");
const containerRender = vi.spyOn(Container.prototype, "render");
const scrollRender = vi.spyOn(ScrollView.prototype, "render");
const { view, theme } = setup();
const lines = view.render(80);
expect(plain(lines)).toEqual([
"─".repeat(80),
" Codex · Usage",
" Account allowance",
expect.stringContaining("escape/ctrl+c/enter close"),
"─".repeat(80),
]);
expect(lines[0]).toBe(new DynamicBorder((text) => theme.fg("border", text)).render(80)[0]);
expect(lines[1]).toBe(
theme.bold(theme.fg("accent", new Text("Codex · Usage", 1, 0).render(80)[0])),
);
expect(borderRender).toHaveBeenCalledWith(80);
expect(textRender).toHaveBeenCalledWith(80);
expect(containerRender).toHaveBeenCalledWith(80);
expect(scrollRender).toHaveBeenCalledWith(80);
});
describe.each([
{ message: `${message}\n中文 👩‍💻 é ${"longword".repeat(30)}` },
{ loading: `Fetching 中文 👩‍💻 ${"longword".repeat(30)}` },
])("native wrapping and bounds for %o", (initial) => {
it("fits supplied widths and terminal heights, including zero and tiny dimensions", () => {
const { view, terminal } = setup(initial);
for (const rows of [0, 1, 2, 3, 4, 5, 6, 7, 10, 24]) {
terminal.rows = rows;
for (const width of [0, 1, 2, 3, 8, 20, 80]) {
const lines = view.render(width);
expect(lines.length, `width=${width}, rows=${rows}`).toBeLessThanOrEqual(
rows >= 7 ? rows - 2 : rows,
);
for (const line of lines) expect(visibleWidth(line)).toBeLessThanOrEqual(width);
if (!width || !rows) expect(lines).toEqual([]);
}
}
});
});
it("keeps title and dismissal visible on short terminals, dropping borders before chrome", () => {
const { view, terminal } = setup({ message }, 3);
expect(plain(view.render(100))).toEqual([
" Codex · Usage",
" line-00",
expect.stringContaining("close"),
]);
terminal.rows = 2;
expect(plain(view.render(100))).toEqual([" Codex · Usage", expect.stringContaining("close")]);
terminal.rows = 1;
expect(plain(view.render(100))).toEqual([expect.stringContaining("close")]);
});
it("scrolls the native viewport by line and page, clamps, and pins header/footer", () => {
const { view, requestRender } = setup({ message }, 12);
const original = plain(view.render(100));
expect(body(view)).toEqual(Array.from({ length: 6 }, (_, index) => ` line-0${index}`));
view.handleInput("\x1b[B");
expect(body(view)[0]).toBe(" line-01");
view.handleInput("\x1b[6~");
expect(body(view)[0]).toBe(" line-07");
view.handleInput("\x1b[5~");
expect(body(view)[0]).toBe(" line-01");
view.handleInput("\x1b[A");
view.handleInput("\x1b[A");
expect(body(view)[0]).toBe(" line-00");
requestRender.mockClear();
for (let index = 0; index < 100; index++) view.handleInput("\x1b[B");
const end = plain(view.render(100));
expect(body(view)[0]).toBe(" line-34");
expect(body(view).at(-1)).toBe(" line-39");
expect(end.slice(0, 2)).toEqual(original.slice(0, 2));
expect(end.slice(-2)).toEqual(original.slice(-2));
expect(requestRender).toHaveBeenCalled();
});
it("reflows on width/height resize and uses native clamping after growing the viewport", () => {
const { view, terminal } = setup({ message }, 12);
view.render(100);
for (let index = 0; index < 100; index++) view.handleInput("\x1b[6~");
expect(body(view)[0]).toBe(" line-34");
terminal.rows = 20;
expect(body(view)[0]).toBe(" line-26");
expect(body(view)).toHaveLength(14);
expect(body(view).at(-1)).toBe(" line-39");
// Text, not the overlay adapter, owns wrapping at the new width.
view.setContent({ message: "A long message with 中文 characters and wrapping words." });
terminal.rows = 40;
const expected = plain(
new Text("A long message with 中文 characters and wrapping words.", 1, 0).render(12),
);
expect(body(view, 12)).toEqual(expected);
terminal.rows = 8;
const small = view.render(12);
expect(small).toHaveLength(6);
expect(plain(small)[1]).toContain("Codex");
expect(plain(small).at(-2)).toContain("escape");
});
it("uses the injected remapped actions for dismissal, scrolling, and every hint", () => {
const { view, keybindings, onClose } = setup({ message }, 12);
keybindings.setUserBindings({
"tui.select.up": "k",
"tui.select.down": "j",
"tui.select.pageUp": "u",
"tui.select.pageDown": "d",
"tui.select.cancel": "ctrl+x",
"tui.select.confirm": "ctrl+y",
});
const footer = plain(view.render(100)).at(-2);
expect(footer).toContain("ctrl+x/ctrl+y close");
expect(footer).toContain("k/j scroll");
expect(footer).toContain("u/d page");
view.handleInput("\x1b[B");
expect(body(view)[0]).toBe(" line-00");
view.handleInput("j");
expect(body(view)[0]).toBe(" line-01");
view.handleInput("d");
expect(body(view)[0]).toBe(" line-07");
view.handleInput("u");
view.handleInput("k");
expect(body(view)[0]).toBe(" line-00");
view.handleInput("\x1b");
view.handleInput("\r");
expect(onClose).not.toHaveBeenCalled();
view.handleInput("\x19");
expect(onClose).toHaveBeenCalledOnce();
});
it.each(["info", "warning", "error"] as const)(
"renders %s from plain state with the current theme on every render",
(level) => {
const { view, theme, requestRender } = setup({ message: "Result", level });
const before = view.render(100);
const color = level === "info" ? "text" : level;
expect(before[2]).toBe(theme.fg(color, new Text("Result", 1, 0).render(100)[0]));
const nextTheme = makeTheme(201);
const fg = vi.spyOn(theme, "fg").mockImplementation((token, text) => nextTheme.fg(token, text));
requestRender.mockClear();
const after = view.render(100);
expect(after[0]).toContain(nextTheme.getFgAnsi("border"));
expect(after[1]).toContain(nextTheme.getFgAnsi("accent"));
expect(after[2]).toContain(nextTheme.getFgAnsi(color));
expect(after.at(-2)).toContain(nextTheme.getFgAnsi("dim"));
expect(plain(after)).toEqual(plain(before));
view.invalidate();
expect(view.render(100)).toEqual(after);
expect(fg).toHaveBeenCalledWith(color, expect.stringContaining("Result"));
expect(requestRender).not.toHaveBeenCalled();
},
);
it("shows loading text even when the body has only one row", () => {
const { view } = setup({ loading: "Fetching allowance" }, 7);
expect(plain(view.render(100))).toEqual([
"─".repeat(100),
" Codex · Usage",
expect.stringContaining("Fetching allowance"),
expect.stringContaining("cancel"),
"─".repeat(100),
]);
});
it("does not advertise disabled bindings or fall back to default dismissal keys", () => {
const { view, keybindings, onClose } = setup();
keybindings.setUserBindings({
"tui.select.cancel": [],
"tui.select.confirm": "ctrl+y",
"tui.select.up": [],
"tui.select.down": [],
"tui.select.pageUp": [],
"tui.select.pageDown": [],
});
expect(plain(view.render(100)).at(-2)).toBe(" ctrl+y close");
view.handleInput("\x1b");
view.handleInput("\x03");
expect(onClose).not.toHaveBeenCalled();
view.handleInput("\x19");
expect(onClose).toHaveBeenCalledOnce();
});
it("animates a real Loader, refreshes its theme without a render loop, and stops on content", () => {
const loaderRender = vi.spyOn(Loader.prototype, "render");
const { view, theme, requestRender, keybindings, onClose } = setup({
loading: "Fetching allowance",
});
keybindings.setUserBindings({ "tui.select.cancel": "ctrl+x", "tui.select.confirm": "ctrl+y" });
expect(vi.getTimerCount()).toBe(1);
const first = view.render(100);
expect(loaderRender).toHaveBeenCalledWith(100);
expect(plain(first).at(-2)).toContain("ctrl+x cancel");
expect(plain(first).at(-2)).not.toContain("ctrl+y");
view.handleInput("\x19");
expect(onClose).not.toHaveBeenCalled();
requestRender.mockClear();
vi.advanceTimersByTime(80);
expect(requestRender).toHaveBeenCalledOnce();
const second = view.render(100);
expect(second).not.toEqual(first);
const nextTheme = makeTheme(201);
vi.spyOn(theme, "fg").mockImplementation((token, text) => nextTheme.fg(token, text));
requestRender.mockClear();
expect(view.render(100).join("\n")).toContain(nextTheme.getFgAnsi("muted"));
expect(requestRender).not.toHaveBeenCalled();
view.invalidate();
view.setContent({ message: "Failed to fetch allowance", level: "error" });
expect(vi.getTimerCount()).toBe(0);
expect(plain(view.render(100)).join("\n")).toContain("Failed to fetch allowance");
expect(plain(view.render(100)).at(-2)).toContain("ctrl+x/ctrl+y close");
requestRender.mockClear();
vi.advanceTimersByTime(1000);
expect(requestRender).not.toHaveBeenCalled();
});
it("resets scroll for replacement content and ignores updates after disposal", () => {
const { view, requestRender } = setup({ message }, 12);
view.render(100);
view.handleInput("\x1b[6~");
view.setContent({ message: "Replacement", level: "warning" });
expect(body(view)).toEqual([" Replacement"]);
view.dispose();
requestRender.mockClear();
view.setContent({ message: "Too late" });
view.handleInput("\x1b[B");
expect(view.render(100)).toEqual([]);
expect(requestRender).not.toHaveBeenCalled();
});
it.each(["\x1b", "\x03", "\r", "dispose"])(
"closes exactly once for %j, including recursive disposal",
(input) => {
const { view, onClose } = setup();
onClose.mockImplementation(() => view.dispose());
if (input === "dispose") view.dispose();
else view.handleInput(input);
view.dispose();
view.handleInput("\x1b");
expect(onClose).toHaveBeenCalledOnce();
},
);
it.each(["cancel", "dispose"])("stops the loader and notifies once on loading %s", (action) => {
const { view, onClose, requestRender, keybindings } = setup({ loading: "Fetching" });
keybindings.setUserBindings({ "tui.select.cancel": "ctrl+x" });
expect(vi.getTimerCount()).toBe(1);
if (action === "cancel") view.handleInput("\x18");
else view.dispose();
expect(vi.getTimerCount()).toBe(0);
view.dispose();
expect(onClose).toHaveBeenCalledOnce();
requestRender.mockClear();
vi.advanceTimersByTime(1000);
expect(requestRender).not.toHaveBeenCalled();
});
+173
View File
@@ -0,0 +1,173 @@
import {
DynamicBorder,
type KeybindingsManager,
type Theme,
} from "@earendil-works/pi-coding-agent";
import {
Container,
Loader,
ScrollView,
Text,
truncateToWidth,
type Component,
type TUI,
} from "@earendil-works/pi-tui";
export interface InspectorContent {
message: string;
level?: "info" | "warning" | "error";
}
/** A compact custom-overlay document, using Pi's native components and scroll state. */
export class CodexInspectorView implements Component {
private readonly body = new Container();
private readonly scroll = new ScrollView(this.body);
private readonly title: Text;
private readonly footer = new Text("", 1, 0);
private readonly border: DynamicBorder;
private content: InspectorContent | undefined;
private loader: Loader | undefined;
private closed = false;
constructor(
private readonly tui: TUI,
private readonly theme: Theme,
private readonly keybindings: KeybindingsManager,
view: string,
private readonly onClose: () => void,
initial: InspectorContent | { loading: string },
) {
this.title = new Text(`Codex · ${view}`, 1, 0);
this.border = new DynamicBorder((text) => this.theme.fg("border", text));
if ("loading" in initial) {
// Loader starts itself in Pi 1.3. Keep its cached text unstyled; apply the
// current theme at render time without invalidate() causing a render loop.
this.loader = new Loader(
tui,
(text) => text,
(text) => text,
initial.loading,
);
this.body.addChild(this.loader);
} else {
this.setContent(initial);
}
}
setContent(content: InspectorContent): void {
if (this.closed) return;
this.stopLoader();
this.content = { ...content };
this.body.clear();
this.body.addChild(new Text(content.message, 1, 0));
this.scroll.scrollToStart();
this.invalidate();
this.tui.requestRender();
}
render(width: number): string[] {
const rows = Math.max(0, Math.floor(this.tui.terminal.rows));
width = Math.max(0, Math.floor(width));
if (this.closed || width === 0 || rows === 0) return [];
// Leave one terminal row above/below when space permits. At tiny heights,
// drop margins, then borders, then body, preserving dismissal and title.
const height = rows - (rows >= 7 ? 2 : 0);
const bordered = height >= 5;
const chromeHeight = Math.min(height, 2) + (bordered ? 2 : 0);
const bodyHeight = Math.max(0, height - chromeHeight);
this.updateFooter();
// Pi 1.3's legacy overlay calls render(width) and clips the document, not a
// viewport layout. ScrollView.render returns ALL child lines. This is the
// only adapter needed: feed public geometry to native scroll/clamp logic,
// then extract its viewport. No internal layout APIs or duplicate offsets.
const rendered = this.scroll.render(width);
// Loader includes a leading spacer for standalone use. Our shared chrome
// already supplies spacing; don't let it hide the spinner in a one-row body.
const document = this.loader ? rendered.slice(1) : rendered;
this.scroll.updateLayout(document.length, bodyHeight, () => this.tui.requestRender());
const visible = document.slice(
this.scroll.scrollTop,
this.scroll.scrollTop + this.scroll.viewportHeight,
);
let color: "text" | "muted" | "warning" | "error" = "text";
if (this.loader) color = "muted";
else if (this.content?.level === "warning" || this.content?.level === "error")
color = this.content.level;
const lines: string[] = [];
if (bordered) lines.push(...this.border.render(width));
if (height >= 2)
lines.push(
...this.title
.render(width)
.slice(0, 1)
.map((line) => this.theme.bold(this.theme.fg("accent", line))),
);
lines.push(...visible.map((line) => this.theme.fg(color, line)));
lines.push(
...this.footer
.render(width)
.slice(0, 1)
.map((line) => this.theme.fg("dim", line)),
);
if (bordered) lines.push(...this.border.render(width));
// Text may encounter a glyph wider than a one-column terminal.
return lines.map((line) => truncateToWidth(line, width, ""));
}
private updateFooter(): void {
const cancelKeys = this.keybindings.getKeys("tui.select.cancel");
const closeKeys = this.loader
? cancelKeys
: [...cancelKeys, ...this.keybindings.getKeys("tui.select.confirm")];
const hints = closeKeys.length
? [`${closeKeys.join("/")} ${this.loader ? "cancel" : "close"}`]
: [];
for (const [up, down, label] of [
["tui.select.up", "tui.select.down", "scroll"],
["tui.select.pageUp", "tui.select.pageDown", "page"],
] as const) {
const keys = [...this.keybindings.getKeys(up), ...this.keybindings.getKeys(down)];
if (keys.length) hints.push(`${keys.join("/")} ${label}`);
}
this.footer.setText(hints.join(" · "));
}
invalidate(): void {
this.scroll.invalidate();
this.title.invalidate();
this.footer.invalidate();
this.border.invalidate();
}
handleInput(data: string): void {
if (this.closed) return;
if (
this.keybindings.matches(data, "tui.select.cancel") ||
(!this.loader && this.keybindings.matches(data, "tui.select.confirm"))
) {
this.dispose();
return;
}
if (this.keybindings.matches(data, "tui.select.up")) this.scroll.scrollBy(-1);
else if (this.keybindings.matches(data, "tui.select.down")) this.scroll.scrollBy(1);
else if (this.keybindings.matches(data, "tui.select.pageUp"))
this.scroll.scrollBy(-this.scroll.viewportHeight);
else if (this.keybindings.matches(data, "tui.select.pageDown"))
this.scroll.scrollBy(this.scroll.viewportHeight);
}
dispose(): void {
if (this.closed) return;
this.closed = true;
this.stopLoader();
// Set the guard first: custom() completion may synchronously dispose us.
this.onClose();
}
private stopLoader(): void {
this.loader?.stop();
this.loader = undefined;
}
}
+82
View File
@@ -0,0 +1,82 @@
import { stripVTControlCharacters } from "node:util";
import { expect, it, vi } from "vitest";
import { TuiMainScreen, type OverlayOptions, type Terminal } from "@earendil-works/pi-tui";
import { customInteractionUI } from "./custom-interaction.test-helper.ts";
import { InspectorController, type InspectorHost } from "./inspector.ts";
import type { InspectorContent } from "./inspector-view.ts";
it.each(
[1, 2, 3, 4, 5, 6, 7, 12, 24].flatMap((rows) => [
{ rows, loading: false },
{ rows, loading: true },
]),
)("keeps content and chrome inside the real Pi overlay host: %o", async ({ rows, loading }) => {
const surface = customInteractionUI({ rows, columns: 100 });
const inspector = new InspectorController();
const terminal = {
rows,
columns: 100,
kittyProtocolActive: false,
start() {},
stop() {},
drainInput: async () => {},
write() {},
moveBy() {},
hideCursor() {},
showCursor() {},
clearLine() {},
clearFromCursor() {},
clearScreen() {},
setTitle() {},
setProgress() {},
setProgramStatus() {},
} satisfies Terminal;
const tui = new TuiMainScreen(terminal);
const ctx: InspectorHost = {
mode: "tui",
sessionManager: { getSessionId: () => "session" },
ui: { custom: surface.custom, notify: vi.fn() },
};
const request = inspector.show(
ctx,
"Overview",
loading
? { loading: "Fetching usage…", load: () => new Promise<InspectorContent>(() => {}) }
: { message: Array.from({ length: 40 }, (_, index) => `Report row ${index}`).join("\n") },
);
let overlay: ReturnType<typeof tui.showOverlay> | undefined;
try {
await Promise.resolve();
const component = surface.modals[0].component!;
const configured = surface.custom.mock.calls[0][1]!.overlayOptions;
const options: OverlayOptions = typeof configured === "function" ? configured() : configured!;
// Exercise native overlay geometry/compositing, not only component.render().
overlay = tui.showOverlay(component, options);
// Reuse the mounted overlay through extreme horizontal resizes. Component-only
// width checks cannot catch off-screen placement caused by host margins.
for (const columns of [100, 76, 24, 3, 2, 1, 100]) {
terminal.columns = columns;
tui.renderNow(true);
const bounds = overlay.getBounds()!;
expect(bounds.row).toBeGreaterThanOrEqual(0);
expect(bounds.row + bounds.height).toBeLessThanOrEqual(rows);
expect(bounds.col).toBeGreaterThanOrEqual(0);
expect(bounds.col + bounds.width).toBeLessThanOrEqual(columns);
if (columns === 100) {
const screen = tui.captureRenderState().previousLines.map(stripVTControlCharacters);
expect(screen.join("\n")).toContain(loading ? "cancel" : "close");
if (rows >= 2) expect(screen.join("\n")).toContain("Codex · Overview");
if (rows >= 3)
expect(screen.join("\n")).toContain(loading ? "Fetching usage" : "Report row");
}
}
expect(ctx.ui.notify).not.toHaveBeenCalled();
component.handleInput!("\u001b");
await request;
} finally {
inspector.dispose();
surface.dispose();
overlay?.hide();
tui.stop();
}
});
+161
View File
@@ -0,0 +1,161 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { CodexInspectorView, type InspectorContent } from "./inspector-view.ts";
export interface InspectorHost {
readonly mode: ExtensionContext["mode"];
readonly sessionManager: Pick<ExtensionContext["sessionManager"], "getSessionId">;
readonly ui: Pick<ExtensionContext["ui"], "notify" | "custom">;
}
export type InspectorSource =
| InspectorContent
| {
loading: string;
load: (signal: AbortSignal) => Promise<InspectorContent>;
/** Commit request-side effects only after ownership and session validation. */
onReady?: () => void;
};
interface Inspection {
readonly controller: AbortController;
readonly sessionId: string;
close?: () => void;
}
/** One owned, transient inspector. No entries, model calls, or background refresh. */
export class InspectorController {
private pending: Inspection | undefined;
private disposed = false;
async show(ctx: InspectorHost, view: string, source: InspectorSource): Promise<void> {
if (this.disposed) return;
this.cancel();
const operation: Inspection = {
controller: new AbortController(),
sessionId: ctx.sessionManager.getSessionId(),
};
this.pending = operation;
try {
if (ctx.mode === "tui") {
await this.showOverlay(operation, ctx, view, source);
} else {
// RPC supports notifications, not terminal components. Preserve a textual fallback.
if ("loading" in source) ctx.ui.notify(source.loading, "info");
const content = await this.readUntilCancelled(operation, source);
if (content && this.canDeliver(operation, ctx)) {
if ("loading" in source) source.onReady?.();
ctx.ui.notify(content.message, content.level ?? "info");
}
}
} finally {
this.cancelOperation(operation);
}
}
cancel(): void {
if (this.pending) this.cancelOperation(this.pending);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.cancel();
}
private showOverlay(
operation: Inspection,
ctx: InspectorHost,
view: string,
source: InspectorSource,
): Promise<void> {
// Keep a single overlay alive through loading and results. Closing this interaction
// never resets the shared editor or steals focus from a newer overlay.
return ctx.ui.custom<void>(
(tui, theme, keybindings, done) => {
let closed = false;
const component = new CodexInspectorView(
tui,
theme,
keybindings,
view,
() => this.cancelOperation(operation),
source,
);
const close = () => {
if (closed) return;
closed = true;
operation.close = undefined;
// Also dispose before adoption: Pi may receive done before using the factory result.
component.dispose();
done();
};
operation.close = close;
if (!this.canDeliver(operation, ctx)) {
this.cancelOperation(operation);
} else if ("loading" in source) {
void this.readUntilCancelled(operation, source).then((content) => {
if (!content || !this.canDeliver(operation, ctx)) {
this.cancelOperation(operation);
return;
}
source.onReady?.();
component.setContent(content);
});
}
return component;
},
{
overlay: true,
// Let Pi clamp the width on resize; fixed margins can exceed tiny terminals.
// The view owns vertical space, including when no margin fits.
overlayOptions: { anchor: "center", width: 76 },
},
);
}
private readUntilCancelled(
operation: Inspection,
source: InspectorSource,
): Promise<InspectorContent | undefined> {
const { signal } = operation.controller;
if (!("loading" in source)) return Promise.resolve(signal.aborted ? undefined : source);
// Observe late results without retaining a host context in an uncooperative request.
return new Promise((resolve) => {
let settled = false;
const finish = (content?: InspectorContent) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(content);
};
const onAbort = () => finish();
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
finish();
return;
}
try {
void source
.load(signal)
.then(finish, () =>
finish({ message: "Could not read Codex information.", level: "warning" }),
);
} catch {
finish({ message: "Could not read Codex information.", level: "warning" });
}
});
}
private canDeliver(operation: Inspection, ctx: InspectorHost): boolean {
// Cancellation must win before accessing getters from a replaced extension runtime.
if (this.disposed || this.pending !== operation || operation.controller.signal.aborted)
return false;
return ctx.sessionManager.getSessionId() === operation.sessionId;
}
private cancelOperation(operation: Inspection): void {
if (this.pending === operation) this.pending = undefined;
operation.controller.abort();
operation.close?.();
}
}
+245 -176
View File
@@ -1,8 +1,10 @@
import { rmSync } from "node:fs";
import { afterAll, afterEach, expect, it, vi } from "vitest";
import { BorderedLoader, initTheme } from "@earendil-works/pi-coding-agent";
import { initTheme } from "@earendil-works/pi-coding-agent";
import { KeybindingsManager, TUI_KEYBINDINGS } from "@earendil-works/pi-tui";
import { CodexAccountError, type CodexAccountRegistry, type fetchCodexUsage } from "./client.ts";
import { UsageController, type UsageHost } from "./usage-controller.ts";
import { InspectorController } from "./inspector.ts";
import { customInteractionUI } from "./custom-interaction.test-helper.ts";
const directory = await vi.hoisted(async () => {
@@ -24,49 +26,47 @@ afterAll(() => {
rmSync(directory, { recursive: true, force: true });
});
it("renders and animates the native bordered loader, then closes before notifying", async () => {
it("keeps one inspector open from animated loading to result until confirmation", async () => {
vi.useFakeTimers();
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
await Promise.resolve();
let settled = false;
const request = f.controller.report(f.ctx).then(() => {
settled = true;
});
await flush();
const modal = f.ctx.modals[0];
const loader = modal.component!;
expect(loader).toBeInstanceOf(BorderedLoader);
const lines = loader.render(60);
expect(lines[0]).toBe("─".repeat(60));
expect(lines.at(-1)).toBe("─".repeat(60));
expect(lines.join("\n")).toContain("Fetching Codex usage…");
expect(lines.join("\n")).toMatch(/esc.*cancel/i);
const renderCount = f.ctx.requestRender.mock.calls.length;
const component = modal.component!;
expect(modal.lines.join("\n")).toContain("Codex · Usage");
expect(modal.lines.join("\n")).toContain("Fetching Codex usage…");
expect(modal.lines.join("\n")).toMatch(/esc.*cancel/i);
const renderCount = modal.requestRender.mock.calls.length;
const loadingLines = [...modal.lines];
vi.advanceTimersByTime(160);
expect(f.ctx.requestRender.mock.calls.length).toBeGreaterThan(renderCount);
expect(loader.render(60)).not.toEqual(lines);
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
expect(modal.requestRender.mock.calls.length).toBeGreaterThan(renderCount);
expect(modal.lines).not.toEqual(loadingLines);
expect(f.fetchUsage).toHaveBeenCalledExactlyOnceWith(
f.ctx.modelRegistry,
expect.any(AbortSignal),
);
const signal = f.fetchUsage.mock.calls[0][1]!;
const removeAbort = vi.spyOn(signal, "removeEventListener");
const removeLoaderAbort = vi.spyOn((loader as BorderedLoader).signal, "removeEventListener");
const notify = f.ctx.ui.notify;
notify.mockImplementation(() => {
expect(modal.closed).toBe(true);
expect(modal.close).toHaveBeenCalledOnce();
expect(signal.aborted).toBe(false);
expire(f.ctx);
});
response.resolve({ plan_type: "plus" });
await flush();
expect(modal.component).toBe(component);
expect(modal.lines.join("\n")).toContain("Plan: plus");
expect(modal.lines.join("\n")).not.toContain("Fetching Codex usage");
expect(modal.closed).toBe(false);
expect(settled).toBe(false);
expect(f.ctx.ui.custom).toHaveBeenCalledOnce();
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
const readyRenderCount = modal.requestRender.mock.calls.length;
vi.advanceTimersByTime(320);
expect(modal.requestRender).toHaveBeenCalledTimes(readyRenderCount);
component.handleInput!("\r");
await request;
expect(notify).toHaveBeenCalledExactlyOnceWith(expect.stringContaining("Plan: plus"), "info");
expect(removeAbort).toHaveBeenCalledWith("abort", expect.any(Function));
expect(removeLoaderAbort).toHaveBeenCalledWith("abort", expect.any(Function));
const stoppedCount = f.ctx.requestRender.mock.calls.length;
vi.advanceTimersByTime(160);
expect(f.ctx.requestRender).toHaveBeenCalledTimes(stoppedCount);
expect(settled).toBe(true);
expect(modal.close).toHaveBeenCalledOnce();
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
});
it.each(["success", "error"] as const)(
@@ -76,71 +76,69 @@ it.each(["success", "error"] as const)(
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
await Promise.resolve();
await flush();
const modal = f.ctx.modals[0];
const signal = f.fetchUsage.mock.calls[0][1]!;
const loader = modal.component as BorderedLoader;
const removeAbort = vi.spyOn(signal, "removeEventListener");
const removeLoaderAbort = vi.spyOn(loader.signal, "removeEventListener");
loader.handleInput("\u001b");
expect(loader.signal.aborted).toBe(true);
modal.component!.handleInput!("\u001b");
expect(signal.aborted).toBe(true);
expect(modal.closed).toBe(true);
expect(modal.close).toHaveBeenCalledOnce();
await request; // No need to settle the uncooperative fetch.
expect(removeAbort).toHaveBeenCalledWith("abort", expect.any(Function));
expect(removeLoaderAbort).toHaveBeenCalledWith("abort", expect.any(Function));
await request;
const notify = f.ctx.ui.notify;
const renders = modal.requestRender.mock.calls.length;
expire(f.ctx);
if (outcome === "error") response.reject(new Error("private late failure"));
if (outcome === "error") response.reject(new CodexAccountError("http", "Late limit", 60_000));
else response.resolve({ plan_type: "late-plan" });
await response.promise.catch(() => {});
await flush();
expect(notify).not.toHaveBeenCalled();
expect(modal.requestRender).toHaveBeenCalledTimes(renders);
expect(modal.close).toHaveBeenCalledOnce();
const fresh = host();
const next = f.controller.report(fresh);
await flush();
expect(f.fetchUsage).toHaveBeenCalledTimes(2); // A stale rate limit cannot consume retry budget.
fresh.modals[0].component!.handleInput!("\r");
await next;
},
);
it("treats external component disposal as cancellation, not just an animation stop", async () => {
it("external component disposal cancels the fetch and settles without waiting", async () => {
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
await Promise.resolve();
await flush();
const modal = f.ctx.modals[0];
const loader = modal.component as BorderedLoader;
loader.dispose();
expect(loader.signal.aborted).toBe(false); // Native disposal itself does not abort.
modal.component!.dispose!();
expect(f.fetchUsage.mock.calls[0][1]!.aborted).toBe(true);
expect(modal.closed).toBe(true);
await request;
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
response.resolve({ plan_type: "late" });
await response.promise;
await flush();
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
expect(modal.close).toHaveBeenCalledOnce();
});
it.each(["cancel", "dispose"] as const)(
"%s closes only the owned modal with no expired context access or late delivery",
"%s closes only its owned modal without expired context access or late delivery",
async (action) => {
for (const fails of [false, true]) {
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
await Promise.resolve();
await flush();
const modal = f.ctx.modals[0];
const signal = f.fetchUsage.mock.calls[0][1]!;
const notify = f.ctx.ui.notify;
expire(f.ctx);
f.controller[action]();
expect(signal.aborted).toBe(true);
if (action === "dispose") f.controller.dispose();
f.inspector[action]();
expect(f.fetchUsage.mock.calls[0][1]!.aborted).toBe(true);
expect(modal.closed).toBe(true);
expect(modal.close).toHaveBeenCalledOnce();
f.controller[action]();
f.inspector[action]();
await request;
if (fails) response.reject(new Error("private late failure"));
else response.resolve({ plan_type: "late" });
await response.promise.catch(() => {});
await flush();
expect(notify).not.toHaveBeenCalled();
expect(modal.close).toHaveBeenCalledOnce();
if (action === "dispose") {
@@ -151,7 +149,21 @@ it.each(["cancel", "dispose"] as const)(
},
);
it("supersedes before adoption and stops the old animation before any manual disposal", async () => {
it("leaves the shared overview open when only the usage service is disposed", async () => {
const f = fixture();
const overview = f.inspector.show(f.ctx, "Overview", { message: "Local state" });
await flush();
f.controller.dispose();
expect(f.ctx.modals[0].closed).toBe(false);
expire(f.ctx);
await f.controller.report(f.ctx);
expect(f.fetchUsage).not.toHaveBeenCalled();
f.inspector.dispose();
await overview;
expect(f.ctx.modals[0].close).toHaveBeenCalledOnce();
});
it("supersedes before adoption and stops the old animation without manual disposal", async () => {
vi.useFakeTimers();
const f = fixture();
const first = deferred<unknown>();
@@ -159,43 +171,41 @@ it("supersedes before adoption and stops the old animation before any manual dis
f.fetchUsage.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
const oldRequest = f.controller.report(f.ctx);
const oldModal = f.ctx.modals[0];
const oldSignal = f.fetchUsage.mock.calls[0][1]!;
const oldNotify = f.ctx.ui.notify;
expire(f.ctx);
const fresh = host("tui", f.ctx.surface);
const newRequest = f.controller.report(fresh);
expect(oldSignal.aborted).toBe(true);
expect(f.fetchUsage.mock.calls[0][1]!.aborted).toBe(true);
expect(oldModal.closed).toBe(true);
await oldRequest;
await Promise.resolve();
await flush();
const newModal = fresh.modals[1];
expect(oldModal.adopted).toBe(false);
expect(newModal.adopted).toBe(true);
expect(newModal.closed).toBe(false);
expect(f.ctx.surface.focusedComponent).toBe(newModal.component);
expect(f.ctx.surface.components).toEqual([f.ctx.surface.mainEditor, newModal.component]);
expect(fresh.surface.focusedComponent).toBe(newModal.component);
expect(fresh.surface.components).toEqual([fresh.surface.mainEditor, newModal.component]);
const oldRenderCount = oldModal.requestRender.mock.calls.length;
const newRenderCount = newModal.requestRender.mock.calls.length;
vi.advanceTimersByTime(320);
// This must precede manual disposal/fixture cleanup: early done is not adopted by Pi.
expect(oldModal.requestRender).toHaveBeenCalledTimes(oldRenderCount);
expect(newModal.requestRender.mock.calls.length).toBeGreaterThan(newRenderCount);
oldModal.component!.dispose?.();
(oldModal.component as BorderedLoader).handleInput("\u001b");
oldModal.component!.handleInput!("\u001b");
first.reject(new CodexAccountError("http", "Late rate limit", 60_000));
await first.promise.catch(() => {});
expect(newModal.closed).toBe(false);
await flush();
expect(newModal.close).not.toHaveBeenCalled();
expect(oldNotify).not.toHaveBeenCalled();
second.resolve({ plan_type: "new-plan" });
await flush();
expect(newModal.lines.join("\n")).toContain("Plan: new-plan");
expect(newModal.closed).toBe(false);
newModal.component!.handleInput!("\r");
await newRequest;
expect(newModal.close).toHaveBeenCalledOnce();
expect(fresh.ui.notify).toHaveBeenCalledExactlyOnceWith(
expect.stringContaining("Plan: new-plan"),
"info",
);
await f.controller.report(fresh);
expect(f.fetchUsage).toHaveBeenCalledTimes(3); // The stale rate limit did not apply.
const third = f.controller.report(fresh);
await flush();
expect(f.fetchUsage).toHaveBeenCalledTimes(3);
fresh.modals[2].component!.handleInput!("\u001b");
await third;
});
it.each([
@@ -203,73 +213,66 @@ it.each([
["complete", true],
["cancel", false],
["cancel", true],
] as const)(
"old usage %s preserves a newer interaction (overlay: %s) on the same UI surface",
async (action, overlay) => {
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
await Promise.resolve();
const usageModal = f.ctx.modals[0];
const surface = f.ctx.surface;
expect(f.ctx.ui.custom).toHaveBeenCalledWith(expect.any(Function), { overlay: true });
expect(surface.focusedComponent).toBe(usageModal.component);
expect(surface.components).toEqual([surface.mainEditor, usageModal.component]);
const newerComponent = {
render: () => ["newer interaction"],
invalidate() {},
dispose: vi.fn(),
};
const newerRequest = f.ctx.ui.custom<void>(() => newerComponent, { overlay });
await Promise.resolve();
const newerModal = f.ctx.modals[1];
] as const)("old usage %s preserves a newer interaction (overlay: %s)", async (action, overlay) => {
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
await flush();
const usageModal = f.ctx.modals[0];
const surface = f.ctx.surface;
expect(f.ctx.ui.custom).toHaveBeenCalledWith(
expect.any(Function),
expect.objectContaining({ overlay: true }),
);
const newerComponent = { render: () => ["newer interaction"], invalidate() {}, dispose: vi.fn() };
const newerRequest = f.ctx.ui.custom<void>(() => newerComponent, { overlay });
await flush();
const newerModal = f.ctx.modals[1];
expect(surface.focusedComponent).toBe(newerComponent);
if (action === "cancel") f.inspector.cancel();
else {
response.resolve({ plan_type: "plus" });
await flush();
expect(usageModal.closed).toBe(false);
expect(usageModal.lines.join("\n")).toContain("Plan: plus");
expect(surface.focusedComponent).toBe(newerComponent);
expect(surface.editorComponent).toBe(overlay ? surface.mainEditor : newerComponent);
expect(surface.components).toContain(usageModal.component);
expect(surface.components).toContain(newerComponent);
usageModal.component!.handleInput!("\u001b");
}
await request;
expect(usageModal.closed).toBe(true);
expect(newerModal.close).not.toHaveBeenCalled();
expect(newerComponent.dispose).not.toHaveBeenCalled();
expect(surface.focusedComponent).toBe(newerComponent);
expect(surface.editorComponent).toBe(overlay ? surface.mainEditor : newerComponent);
expect(surface.components).toEqual(
overlay ? [surface.mainEditor, newerComponent] : [newerComponent],
);
expect(newerModal.lines).toEqual(["newer interaction"]);
newerModal.close();
await newerRequest;
expect(newerComponent.dispose).toHaveBeenCalledOnce();
expect(surface.focusedComponent).toBe(surface.mainEditor);
if (action === "cancel") response.resolve({ plan_type: "late" });
await flush();
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
});
if (action === "cancel") f.controller.cancel();
else response.resolve({ plan_type: "plus" });
await request;
expect(usageModal.closed).toBe(true);
expect(newerModal.closed).toBe(false);
expect(newerModal.close).not.toHaveBeenCalled();
expect(newerComponent.dispose).not.toHaveBeenCalled();
expect(surface.focusedComponent).toBe(newerComponent);
expect(surface.editorComponent).toBe(overlay ? surface.mainEditor : newerComponent);
expect(surface.components).toEqual(
overlay ? [surface.mainEditor, newerComponent] : [newerComponent],
);
expect(newerModal.lines).toEqual(["newer interaction"]);
// The newer interaction still owns its completion and can return focus normally.
newerModal.close();
await newerRequest;
expect(newerComponent.dispose).toHaveBeenCalledOnce();
expect(surface.focusedComponent).toBe(surface.mainEditor);
expect(surface.components).toEqual([surface.mainEditor]);
if (action === "cancel") {
response.resolve({ plan_type: "late" });
await response.promise;
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
}
},
);
it("suppresses a report when the session changes without an explicit cancellation", async () => {
it("dismisses delivery after session ID changes without explicit cancellation", async () => {
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
f.ctx.sessionManager.getSessionId.mockReturnValue("replacement-session");
response.resolve({ plan_type: "plus" });
response.reject(new CodexAccountError("http", "Stale rate limit", 60_000));
await request;
expect(f.ctx.modals[0].closed).toBe(true);
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
f.controller.cancel();
expect(f.ctx.modals[0].close).toHaveBeenCalledOnce();
const next = f.controller.report(f.ctx);
await flush();
expect(f.fetchUsage).toHaveBeenCalledTimes(2);
f.ctx.modals[1].component!.handleInput!("\r");
await next;
});
it.each([
@@ -291,43 +294,104 @@ it.each([
new CodexAccountError("timeout", "Codex account request timed out."),
"Codex account request timed out.",
],
] as const)("closes before reporting an %s", async (_name, data, error, message) => {
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const request = f.controller.report(f.ctx);
f.ctx.ui.notify.mockImplementation(() => {
expect(f.ctx.modals[0].closed).toBe(true);
expect(f.ctx.modals[0].close).toHaveBeenCalledOnce();
});
if (error) response.reject(error);
else response.resolve(data);
await request;
expect(f.ctx.ui.notify).toHaveBeenCalledExactlyOnceWith(message, "warning");
});
] as const)(
"keeps an %s in the same modal without notification",
async (_name, data, error, message) => {
const f = fixture();
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
let settled = false;
const request = f.controller.report(f.ctx).then(() => {
settled = true;
});
await flush();
if (error) response.reject(error);
else response.resolve(data);
await flush();
const modal = f.ctx.modals[0];
expect(modal.lines.join("\n")).toContain(message);
expect(modal.lines.join("\n")).not.toMatch(
/private failure|do-not-display|Fetching Codex usage/,
);
expect(settled).toBe(false);
expect(modal.closed).toBe(false);
expect(f.ctx.ui.custom).toHaveBeenCalledOnce();
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
modal.component!.handleInput!("\u001b");
await request;
},
);
it("honors retry-after without polling or opening a loader for a blocked request", async () => {
it("keeps retry-after errors in the inspector and opens a static warning without fetch or spinner", async () => {
vi.useFakeTimers();
const f = fixture();
f.fetchUsage.mockRejectedValueOnce(
new CodexAccountError("http", "Codex account service is rate limited.", 60_000),
);
await f.controller.report(f.ctx);
await f.controller.report(f.ctx);
const first = f.controller.report(f.ctx);
await flush();
expect(f.ctx.modals[0].lines.join("\n")).toContain("Codex account service is rate limited.");
expect(f.ctx.modals[0].closed).toBe(false);
// A subsequent report supersedes the still-open rate-limit modal.
let settled = false;
const blocked = f.controller.report(f.ctx).then(() => {
settled = true;
});
await first;
await flush();
const warning = f.ctx.modals[1];
expect(f.fetchUsage).toHaveBeenCalledOnce();
expect(f.ctx.ui.custom).toHaveBeenCalledOnce();
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(
expect(warning.lines.join("\n")).toContain(
"Codex account service is rate limited. Try again in 60s.",
"warning",
);
expect(warning.renders.some((lines) => lines.join("\n").includes("Fetching Codex usage"))).toBe(
false,
);
const count = warning.requestRender.mock.calls.length;
vi.advanceTimersByTime(320);
expect(warning.requestRender).toHaveBeenCalledTimes(count);
expect(settled).toBe(false);
warning.component!.handleInput!("\r");
await blocked;
f.clock.now += 60_000;
await f.controller.report(f.ctx);
const next = f.controller.report(f.ctx);
await flush();
expect(f.fetchUsage).toHaveBeenCalledTimes(2);
expect(f.ctx.ui.custom).toHaveBeenCalledTimes(2);
expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Plan: plus"), "info");
expect(f.ctx.modals[2].lines.join("\n")).toContain("Plan: plus");
f.ctx.modals[2].component!.handleInput!("\r");
await next;
expect(f.ctx.ui.notify).not.toHaveBeenCalled();
});
it("uses the injected configured cancel and confirm actions", async () => {
const surface = customInteractionUI({
keybindings: new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.select.cancel": "ctrl+x",
"tui.select.confirm": "ctrl+y",
}),
});
const f = fixture("tui", surface);
const request = f.controller.report(f.ctx);
await flush();
const modal = f.ctx.modals[0];
modal.component!.handleInput!("\u001b");
modal.component!.handleInput!("\r");
expect(modal.closed).toBe(false);
modal.component!.handleInput!("\u0019");
await request;
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const pending = f.controller.report(f.ctx);
await flush();
f.ctx.modals[1].component!.handleInput!("\u0018");
await pending;
expect(f.fetchUsage.mock.calls[1][1]!.aborted).toBe(true);
response.resolve({ plan_type: "late" });
await flush();
});
it.each(["rpc", "print", "json"] as const)(
"%s uses only progress notifications, never custom terminal UI",
"%s uses progress and final notifications only",
async (mode) => {
for (const fails of [false, true]) {
const f = fixture(mode);
@@ -346,13 +410,13 @@ it.each(["rpc", "print", "json"] as const)(
);
it.each(["rpc", "print"] as const)(
"%s cancellation settles without waiting, and another request can start",
"%s cancellation settles without waiting and permits another request",
async (mode) => {
const f = fixture(mode);
const response = deferred<unknown>();
f.fetchUsage.mockReturnValueOnce(response.promise);
const oldRequest = f.controller.report(f.ctx);
f.controller.cancel();
f.inspector.cancel();
await oldRequest;
expect(f.fetchUsage.mock.calls[0][1]!.aborted).toBe(true);
await f.controller.report(f.ctx);
@@ -362,22 +426,21 @@ it.each(["rpc", "print"] as const)(
[expect.stringContaining("Plan: plus"), "info"],
]);
response.reject(new Error("late failure"));
await response.promise.catch(() => {});
await flush();
expect(f.ctx.ui.notify).toHaveBeenCalledTimes(3);
expect(f.ctx.ui.custom).not.toHaveBeenCalled();
},
);
it("propagates a final notification failure without touching the invalidated context again", async () => {
const f = fixture();
f.ctx.ui.notify.mockImplementation(() => {
it("propagates a non-TUI final notification failure without touching expired context again", async () => {
const f = fixture("print");
f.ctx.ui.notify.mockImplementation((message) => {
if (message === "Fetching Codex usage…") return;
expire(f.ctx);
throw new Error("UI failure");
});
await expect(f.controller.report(f.ctx)).rejects.toThrow("UI failure");
expect(f.ctx.modals[0].closed).toBe(true);
f.controller.cancel();
expect(f.ctx.modals[0].close).toHaveBeenCalledOnce();
f.inspector.cancel();
});
function deferred<T>() {
@@ -390,16 +453,18 @@ function deferred<T>() {
return { promise, resolve, reject };
}
async function flush() {
// Drain promise-only controller, formatting, and custom-adoption continuations without advancing animations.
for (let i = 0; i < 20; i++) await Promise.resolve();
}
function host(mode: UsageHost["mode"] = "tui", surface = customInteractionUI()) {
cleanup.push(() => surface.dispose());
return {
mode,
modelRegistry: {} as CodexAccountRegistry,
sessionManager: { getSessionId: vi.fn(() => "session") },
ui: {
notify: vi.fn<UsageHost["ui"]["notify"]>(),
custom: surface.custom,
},
ui: { notify: vi.fn<UsageHost["ui"]["notify"]>(), custom: surface.custom },
modals: surface.modals,
requestRender: surface.requestRender,
surface,
@@ -410,13 +475,17 @@ function host(mode: UsageHost["mode"] = "tui", surface = customInteractionUI())
};
}
function fixture(mode: UsageHost["mode"] = "tui") {
function fixture(mode: UsageHost["mode"] = "tui", surface = customInteractionUI()) {
const clock = { now: Date.parse("2026-01-10T12:00:00Z") };
const fetchUsage = vi.fn<typeof fetchCodexUsage>(async () => ({ plan_type: "plus" }));
const ctx = host(mode);
const controller = new UsageController(fetchUsage, () => clock.now);
cleanup.push(() => controller.dispose());
return { controller, ctx, fetchUsage, clock };
const ctx = host(mode, surface);
const inspector = new InspectorController();
const controller = new UsageController(inspector, fetchUsage, () => clock.now);
cleanup.push(() => {
controller.dispose();
inspector.dispose();
});
return { controller, inspector, ctx, fetchUsage, clock };
}
function expire(ctx: UsageHost) {
+54 -150
View File
@@ -1,180 +1,84 @@
import { BorderedLoader, type ExtensionContext } from "@earendil-works/pi-coding-agent";
import { CodexAccountError, fetchCodexUsage, type CodexAccountRegistry } from "./client.ts";
import type { InspectorController, InspectorHost } from "./inspector.ts";
import type { InspectorContent } from "./inspector-view.ts";
import { formatCodexUsage } from "./usage.ts";
export interface UsageHost {
readonly mode: ExtensionContext["mode"];
export interface UsageHost extends InspectorHost {
readonly modelRegistry: CodexAccountRegistry;
readonly sessionManager: Pick<ExtensionContext["sessionManager"], "getSessionId">;
readonly ui: Pick<ExtensionContext["ui"], "notify" | "custom">;
}
type UsageResult = { data: unknown } | { error: unknown };
interface PendingUsage {
readonly controller: AbortController;
readonly sessionId: string;
// Owns only this interaction's loader and completion callback, never its context.
closeLoader?: () => void;
interface UsageReport {
content: InspectorContent;
retryAfterMs?: number;
}
export class UsageController {
private pending: PendingUsage | undefined;
private retryAt = 0;
private disposed = false;
constructor(
private readonly inspector: InspectorController,
private readonly fetchUsage: typeof fetchCodexUsage = fetchCodexUsage,
private readonly now: () => number = Date.now,
) {}
async report(ctx: UsageHost): Promise<void> {
if (this.disposed) return;
this.cancel();
if (this.now() < this.retryAt) {
const seconds = Math.ceil((this.retryAt - this.now()) / 1000);
ctx.ui.notify(`Codex account service is rate limited. Try again in ${seconds}s.`, "warning");
return;
return this.inspector.show(ctx, "Usage", {
message: `Codex account service is rate limited. Try again in ${seconds}s.`,
level: "warning",
});
}
const operation: PendingUsage = {
controller: new AbortController(),
sessionId: ctx.sessionManager.getSessionId(),
};
this.pending = operation;
let result: UsageResult | undefined;
try {
const registry = ctx.modelRegistry;
if (ctx.mode === "tui") {
result = await this.readUsageWithLoader(operation, registry, ctx.ui);
} else {
// RPC has UI, but cannot host terminal components. Print/JSON use this too.
ctx.ui.notify("Fetching Codex usage…", "info");
result = await this.readUsageUntilCancelled(operation, registry);
}
} catch (error) {
this.cancelOperation(operation);
throw error;
}
// The custom interaction has closed before any final notification or context access.
if (!this.canDeliverReport(operation, ctx) || result === undefined) {
this.cancelOperation(operation);
return;
}
let message: string;
let level: "info" | "warning" = "warning";
if ("error" in result) {
const error = result.error;
message =
error instanceof CodexAccountError ? error.message : "Could not read Codex account usage.";
if (error instanceof CodexAccountError && error.retryAfterMs !== undefined) {
this.retryAt = this.now() + error.retryAfterMs;
}
} else {
const report = formatCodexUsage(result.data, this.now());
message = report ?? "Codex account service returned an unrecognized usage response.";
if (report !== undefined) level = "info";
}
// A notification may reload the extension. Nothing may use ctx after this call.
this.pending = undefined;
ctx.ui.notify(message, level);
}
cancel(): void {
const operation = this.pending;
if (operation) this.cancelOperation(operation);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.cancel();
}
private readUsageWithLoader(
operation: PendingUsage,
registry: CodexAccountRegistry,
ui: UsageHost["ui"],
): Promise<UsageResult | undefined> {
// An owned overlay closes only itself; Pi 1.3 non-overlay completion resets the
// editor/focus even when a newer interaction has taken over the shared surface.
return ui.custom<UsageResult | undefined>(
(tui, theme, _kb, done) => {
const loader = new BorderedLoader(tui, theme, "Fetching Codex usage…");
let closed = false;
const onLoaderAbort = () => this.cancelOperation(operation);
const closeLoader = (result?: UsageResult) => {
if (closed) return;
closed = true;
operation.closeLoader = undefined;
loader.signal.removeEventListener("abort", onLoaderAbort);
// Dispose explicitly too: Pi may receive done before adopting the factory result.
loader.dispose();
done(result);
};
operation.closeLoader = () => closeLoader();
loader.signal.addEventListener("abort", onLoaderAbort, { once: true });
// Native dispose stops animation, but does not abort its signal. External disposal
// must cancel our HTTP wait; disposal during successful close must not cancel delivery.
const disposeLoader = loader.dispose.bind(loader);
loader.dispose = () => {
disposeLoader();
if (!closed) this.cancelOperation(operation);
};
if (this.pending !== operation || operation.controller.signal.aborted) closeLoader();
else void this.readUsageUntilCancelled(operation, registry).then(closeLoader);
return loader;
// The request retains only its registry and signal, not a potentially replaced UI context.
const registry = ctx.modelRegistry;
let retryAfterMs: number | undefined;
return this.inspector.show(ctx, "Usage", {
loading: "Fetching Codex usage…",
load: async (signal) => {
const report = await this.readUsage(registry, signal);
retryAfterMs = report.retryAfterMs;
return report.content;
},
onReady: () => {
if (retryAfterMs !== undefined) this.retryAt = this.now() + retryAfterMs;
},
{ overlay: true },
);
}
private readUsageUntilCancelled(
operation: PendingUsage,
registry: CodexAccountRegistry,
): Promise<UsageResult | undefined> {
const { signal } = operation.controller;
// Do not depend on an injected fetch implementation observing AbortSignal. Its late
// fulfillment/rejection is observed, but can neither retain ctx nor reopen/close UI.
return new Promise((resolve) => {
let settled = false;
const finishRead = (result?: UsageResult) => {
if (settled) return;
settled = true;
signal.removeEventListener("abort", onReadAbort);
resolve(result);
};
const onReadAbort = () => finishRead();
signal.addEventListener("abort", onReadAbort, { once: true });
if (signal.aborted) {
finishRead();
return;
}
try {
void this.fetchUsage(registry, signal).then(
(data) => finishRead({ data }),
(error: unknown) => finishRead({ error }),
);
} catch (error) {
finishRead({ error });
}
});
}
private cancelOperation(operation: PendingUsage): void {
if (this.pending === operation) this.pending = undefined;
operation.controller.abort();
operation.closeLoader?.();
dispose(): void {
// The extension owns the shared inspector and cancels its work independently.
this.disposed = true;
}
private canDeliverReport(operation: PendingUsage, ctx: UsageHost): boolean {
// Invalidation must win before touching any potentially expired context getter.
if (this.disposed || this.pending !== operation || operation.controller.signal.aborted)
return false;
return ctx.sessionManager.getSessionId() === operation.sessionId;
private async readUsage(
registry: CodexAccountRegistry,
signal: AbortSignal,
): Promise<UsageReport> {
try {
const data = await this.fetchUsage(registry, signal);
if (signal.aborted) return { content: { message: "" } };
const report = formatCodexUsage(data, this.now());
return {
content: {
message: report ?? "Codex account service returned an unrecognized usage response.",
level: report === undefined ? "warning" : "info",
},
};
} catch (error) {
// A cancelled request must not change the retry budget of a newer inspection.
if (signal.aborted || this.disposed) return { content: { message: "" } };
return {
content: {
message:
error instanceof CodexAccountError
? error.message
: "Could not read Codex account usage.",
level: "warning",
},
retryAfterMs: error instanceof CodexAccountError ? error.retryAfterMs : undefined,
};
}
}
}
+205 -53
View File
@@ -8,7 +8,6 @@ import {
type OAuthCredential,
} from "@earendil-works/pi-ai";
import {
BorderedLoader,
createAgentSession,
DefaultResourceLoader,
initTheme,
@@ -35,73 +34,222 @@ afterAll(() => {
rmSync(directory, { recursive: true, force: true });
});
it("SDK usage reuses Pi's OAuth without a model turn or credential write", async () => {
const usageResponse = {
plan_type: "plus",
rate_limit: {
primary_window: {
used_percent: 25,
limit_window_seconds: 18_000,
reset_after_seconds: 1800,
},
},
};
const accountRequest = vi.fn<typeof fetch>(async (url) => {
if (url !== "https://chatgpt.com/backend-api/wham/usage") {
throw new Error("Unexpected network request");
}
return new Response(JSON.stringify(usageResponse));
it("SDK read-only aliases open local inspectors without network, auth reads, model turns or session entries", async () => {
const accountRequest = vi.fn<typeof fetch>(async () => {
throw new Error("Network forbidden");
});
vi.stubGlobal("fetch", accountRequest);
const { runtime, model, credentialReads, credentialWrites, modelTurns } =
await createOfflineCodexRuntime();
const f = await openCodexExtensionSession(runtime, model);
try {
const entries = structuredClone(f.sessionManager.getEntries());
const branch = structuredClone(f.sessionManager.getBranch());
credentialReads.mockClear();
let dashboard: string | undefined;
for (const [index, command] of [
"/eleith:codex",
"/eleith:codex context",
"/eleith:codex fast",
].entries()) {
let settled = false;
const request = f.session.prompt(command).then(() => {
settled = true;
});
await vi.waitFor(() => expect(f.modals[index]?.adopted).toBe(true));
const modal = f.modals[index];
const text = modal.lines.join("\n");
expect(text).toContain("Codex · Overview");
expect(text).toContain("Context:");
expect(text).toContain("Fast:");
if (dashboard === undefined) dashboard = text;
else expect(text).toBe(dashboard);
expect(settled).toBe(false);
expect(modal.closed).toBe(false);
modal.component!.handleInput!(index % 2 ? "\u001b" : "\r");
await request;
expect(modal.close).toHaveBeenCalledOnce();
}
expect(f.custom).toHaveBeenCalledTimes(3);
for (const call of f.custom.mock.calls) expect(call[1]).toMatchObject({ overlay: true });
expect(f.notify).not.toHaveBeenCalled();
expect(f.setStatus).not.toHaveBeenCalled();
expect(accountRequest).not.toHaveBeenCalled();
expect(credentialReads).not.toHaveBeenCalled();
expect(credentialWrites).not.toHaveBeenCalled();
expect(modelTurns).not.toHaveBeenCalled();
expect(f.session.model).toEqual(model);
expect(f.sessionManager.getEntries()).toEqual(entries);
expect(f.sessionManager.getBranch()).toEqual(branch);
expect(f.extensionErrors).toEqual([]);
} finally {
await f.closeSession();
}
});
it("SDK usage reuses Pi OAuth and updates one loading modal to a persistent result without a model turn", async () => {
const response = deferred<Response>();
const accountRequest = vi.fn<typeof fetch>(() => response.promise);
vi.stubGlobal("fetch", accountRequest);
const { runtime, model, accessToken, credentialWrites, modelTurns } =
await createOfflineCodexRuntime();
const {
session,
sessionManager,
notify,
setStatus,
custom,
modals,
extensionErrors,
closeSession,
} = await openCodexExtensionSession(runtime, model);
const f = await openCodexExtensionSession(runtime, model);
try {
const branch = structuredClone(sessionManager.getBranch());
await session.prompt("/eleith:codex");
expect(accountRequest).not.toHaveBeenCalled();
notify.mockImplementation(() => {
expect(modals[0].closed).toBe(true);
expect(modals[0].close).toHaveBeenCalledOnce();
const entries = structuredClone(f.sessionManager.getEntries());
let settled = false;
const request = f.session.prompt("/eleith:codex usage").then(() => {
settled = true;
});
await session.prompt("/eleith:codex usage");
expect(custom).toHaveBeenCalledOnce();
expect(custom).toHaveBeenCalledWith(expect.any(Function), { overlay: true });
expect(modals[0].component).toBeInstanceOf(BorderedLoader);
expect(modals[0].lines.join("\n")).toContain("Fetching Codex usage…");
expect(modals[0].lines.join("\n")).toMatch(/esc.*cancel/i);
expect(notify).toHaveBeenLastCalledWith(
expect.stringContaining("5h window: 75% remaining (25% used)"),
"info",
await vi.waitFor(() => expect(f.modals[0]?.adopted).toBe(true));
const modal = f.modals[0];
const component = modal.component;
expect(modal.lines.join("\n")).toContain("Codex · Usage");
expect(modal.lines.join("\n")).toContain("Fetching Codex usage…");
expect(modal.lines.join("\n")).toMatch(/esc.*cancel/i);
response.resolve(
new Response(
JSON.stringify({
plan_type: "plus",
rate_limit: {
primary_window: {
used_percent: 25,
limit_window_seconds: 18_000,
reset_after_seconds: 1800,
},
},
}),
),
);
await vi.waitFor(() =>
expect(modal.lines.join("\n")).toContain("5h window: 75% remaining (25% used)"),
);
expect(modal.component).toBe(component);
expect(modal.renders.some((lines) => lines.join("\n").includes("Fetching Codex usage…"))).toBe(
true,
);
expect(modal.closed).toBe(false);
expect(settled).toBe(false);
expect(f.custom).toHaveBeenCalledOnce();
expect(f.custom.mock.calls[0][1]).toMatchObject({ overlay: true });
expect(f.notify).not.toHaveBeenCalled();
expect(f.setStatus).not.toHaveBeenCalled();
expect(accountRequest).toHaveBeenCalledOnce();
expect(setStatus).not.toHaveBeenCalled();
expect(modals[0].close.mock.invocationCallOrder[0]).toBeLessThan(
notify.mock.invocationCallOrder.at(-1)!,
);
expect(accountRequest.mock.calls[0][0]).toBe("https://chatgpt.com/backend-api/wham/usage");
const headers = new Headers(accountRequest.mock.calls[0][1]?.headers);
expect(headers.get("authorization")).toBe(`Bearer ${accessToken}`);
expect(headers.get("chatgpt-account-id")).toBe("sdk-test-account");
expect(sessionManager.getBranch()).toEqual(branch);
expect(session.model).toEqual(model);
component!.handleInput!("\r");
await request;
expect(modal.close).toHaveBeenCalledOnce();
expect(f.notify).not.toHaveBeenCalled();
expect(f.sessionManager.getEntries()).toEqual(entries);
expect(f.session.model).toEqual(model);
expect(credentialWrites).not.toHaveBeenCalled();
expect(modelTurns).not.toHaveBeenCalled();
expect(extensionErrors).toEqual([]);
expect(f.extensionErrors).toEqual([]);
} finally {
await closeSession();
await f.closeSession();
}
});
it("SDK local dashboard replaces pending usage and usage replaces an open dashboard on one shared surface", async () => {
const response = deferred<Response>();
const nextResponse = deferred<Response>();
const accountRequest = vi
.fn<typeof fetch>()
.mockReturnValueOnce(response.promise)
.mockReturnValueOnce(nextResponse.promise);
vi.stubGlobal("fetch", accountRequest);
const { runtime, model, credentialWrites, modelTurns } = await createOfflineCodexRuntime();
const f = await openCodexExtensionSession(runtime, model);
try {
const entries = structuredClone(f.sessionManager.getEntries());
const usage = f.session.prompt("/eleith:codex usage");
await vi.waitFor(() => expect(accountRequest).toHaveBeenCalledOnce());
const first = f.modals[0];
const signal = accountRequest.mock.calls[0][1]?.signal;
const dashboard = f.session.prompt("/eleith:codex context");
await usage; // Must settle even though the network promise ignores abort.
await vi.waitFor(() => expect(f.modals[1]?.adopted).toBe(true));
const second = f.modals[1];
expect(first.closed).toBe(true);
expect(signal?.aborted).toBe(true);
expect(second.lines.join("\n")).toContain("Codex · Overview");
expect(second.closed).toBe(false);
expect(f.surface.focusedComponent).toBe(second.component);
response.resolve(new Response(JSON.stringify({ plan_type: "stale-plan" })));
await Promise.resolve();
const nextUsage = f.session.prompt("/eleith:codex usage");
await dashboard;
await vi.waitFor(() => expect(accountRequest).toHaveBeenCalledTimes(2));
await vi.waitFor(() => expect(f.modals[2]?.adopted).toBe(true));
const third = f.modals[2];
expect(second.closed).toBe(true);
expect(third.lines.join("\n")).toContain("Codex · Usage");
expect(third.lines.join("\n")).toContain("Fetching Codex usage…");
expect(f.surface.focusedComponent).toBe(third.component);
expect(f.surface.components).toEqual([f.surface.mainEditor, third.component]);
nextResponse.resolve(new Response(JSON.stringify({ plan_type: "new-plan" })));
await vi.waitFor(() => expect(third.lines.join("\n")).toContain("Plan: new-plan"));
expect(third.closed).toBe(false);
expect(first.close).toHaveBeenCalledOnce();
expect(second.close).toHaveBeenCalledOnce();
third.component!.handleInput!("\u001b");
await nextUsage;
expect(f.surface.focusedComponent).toBe(f.surface.mainEditor);
expect(f.notify).not.toHaveBeenCalled();
expect(f.sessionManager.getEntries()).toEqual(entries);
expect(credentialWrites).not.toHaveBeenCalled();
expect(modelTurns).not.toHaveBeenCalled();
expect(f.extensionErrors).toEqual([]);
} finally {
await f.closeSession();
}
});
it.each(["model_select", "session_tree", "session_shutdown"] as const)(
"SDK %s dismisses a pending TUI inspector immediately with no late result notification",
async (event) => {
const response = deferred<Response>();
const accountRequest = vi.fn<typeof fetch>(() => response.promise);
vi.stubGlobal("fetch", accountRequest);
const { runtime, model } = await createOfflineCodexRuntime();
const f = await openCodexExtensionSession(runtime, model);
try {
const request = f.session.prompt("/eleith:codex usage");
await vi.waitFor(() => expect(accountRequest).toHaveBeenCalledOnce());
if (event === "model_select")
await f.session.extensionRunner.emit({
type: event,
model,
previousModel: model,
source: "set",
});
else if (event === "session_tree")
await f.session.extensionRunner.emit({ type: event, newLeafId: null, oldLeafId: null });
else await f.session.extensionRunner.emit({ type: event, reason: "quit" });
await request;
expect(accountRequest.mock.calls[0][1]?.signal?.aborted).toBe(true);
expect(f.modals[0].closed).toBe(true);
response.resolve(new Response(JSON.stringify({ plan_type: "late-plan" })));
await Promise.resolve();
expect(f.notify).not.toHaveBeenCalled();
expect(f.modals[0].close).toHaveBeenCalledOnce();
expect(f.extensionErrors).toEqual([]);
} finally {
await f.closeSession();
}
},
);
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
async function createOfflineCodexRuntime() {
const payload = { "https://api.openai.com/auth": { chatgpt_account_id: "sdk-test-account" } };
const accessToken = `e30.${Buffer.from(JSON.stringify(payload)).toString("base64url")}.fake-signature`;
@@ -114,8 +262,11 @@ async function createOfflineCodexRuntime() {
const credentialWrites = vi.fn<CredentialStore["modify"]>(async () => {
throw new Error("Credential writes forbidden");
});
const credentialReads = vi.fn<CredentialStore["read"]>(async (id) =>
id === "openai-codex" ? credential : undefined,
);
const credentials: CredentialStore = {
read: async (id) => (id === "openai-codex" ? credential : undefined),
read: credentialReads,
list: async () => [{ providerId: "openai-codex", type: "oauth" }],
modify: credentialWrites,
delete: async () => {
@@ -138,7 +289,7 @@ async function createOfflineCodexRuntime() {
});
const model = runtime.getModel("openai-codex", "gpt-6-sol");
if (!model) throw new Error("Built-in Codex model missing");
return { runtime, model, accessToken, credentialWrites, modelTurns };
return { runtime, model, accessToken, credentialReads, credentialWrites, modelTurns };
}
async function openCodexExtensionSession(runtime: ModelRuntime, model: Model<Api>) {
@@ -191,6 +342,7 @@ async function openCodexExtensionSession(runtime: ModelRuntime, model: Model<Api
setStatus,
custom,
modals,
surface,
extensionErrors,
async closeSession() {
try {