feat(codex): unify overview and usage inspectors #11
No files matched your search
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user