From 0923c705d905ad895f9314ba99c74a89ca324108 Mon Sep 17 00:00:00 2001 From: eleith Date: Thu, 5 Nov 2026 06:18:27 -0800 Subject: [PATCH] feat(codex): unify overview and usage inspectors --- .changeset/codex-inspectors.md | 5 + extensions/codex/README.md | 4 +- extensions/codex/package.json | 3 +- .../src/custom-interaction.test-helper.ts | 41 +- extensions/codex/src/fast-sdk.test.ts | 3 +- extensions/codex/src/index.test.ts | 137 +++++- extensions/codex/src/index.ts | 15 +- extensions/codex/src/inspector-view.test.ts | 334 ++++++++++++++ extensions/codex/src/inspector-view.ts | 173 +++++++ extensions/codex/src/inspector.test.ts | 82 ++++ extensions/codex/src/inspector.ts | 161 +++++++ extensions/codex/src/usage-controller.test.ts | 421 ++++++++++-------- extensions/codex/src/usage-controller.ts | 204 +++------ extensions/codex/src/usage-sdk.test.ts | 258 ++++++++--- 14 files changed, 1440 insertions(+), 401 deletions(-) create mode 100644 .changeset/codex-inspectors.md create mode 100644 extensions/codex/src/inspector-view.test.ts create mode 100644 extensions/codex/src/inspector-view.ts create mode 100644 extensions/codex/src/inspector.test.ts create mode 100644 extensions/codex/src/inspector.ts diff --git a/.changeset/codex-inspectors.md b/.changeset/codex-inspectors.md new file mode 100644 index 0000000..5029ce1 --- /dev/null +++ b/.changeset/codex-inspectors.md @@ -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. diff --git a/extensions/codex/README.md b/extensions/codex/README.md index 8e1772e..60faffb 100644 --- a/extensions/codex/README.md +++ b/extensions/codex/README.md @@ -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 diff --git a/extensions/codex/package.json b/extensions/codex/package.json index 29740f7..aaa5c8b 100644 --- a/extensions/codex/package.json +++ b/extensions/codex/package.json @@ -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": [ diff --git a/extensions/codex/src/custom-interaction.test-helper.ts b/extensions/codex/src/custom-interaction.test-helper.ts index bc9f21d..7f6d6ce 100644 --- a/extensions/codex/src/custom-interaction.test-helper.ts +++ b/extensions/codex/src/custom-interaction.test-helper.ts @@ -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[0]; type UIComponent = Awaited[0]>>; type CustomFactory = ( tui: Tui, theme: Theme, - kb: KeybindingsManager, + kb: PiKeybindingsManager, done: (result: T) => void, ) => UIComponent | Promise; // 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 void>>; requestRender: ReturnType 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() { diff --git a/extensions/codex/src/fast-sdk.test.ts b/extensions/codex/src/fast-sdk.test.ts index 3e0cfa2..846bd01 100644 --- a/extensions/codex/src/fast-sdk.test.ts +++ b/extensions/codex/src/fast-sdk.test.ts @@ -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), }); diff --git a/extensions/codex/src/index.test.ts b/extensions/codex/src/index.test.ts index cef7cd4..82bdda5 100644 --- a/extensions/codex/src/index.test.ts +++ b/extensions/codex/src/index.test.ts @@ -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()), @@ -29,7 +34,13 @@ afterAll(() => { rmSync(directory, { recursive: true, force: true }); }); -async function load(prefix = "eleith") { +initTheme("dark", false); +const surfaces: ReturnType[] = []; +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(); +} diff --git a/extensions/codex/src/index.ts b/extensions/codex/src/index.ts index 887b2dd..d1815fd 100644 --- a/extensions/codex/src/index.ts +++ b/extensions/codex/src/index.ts @@ -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 { 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 { 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 { 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 { fast.dispose(); try { usage.dispose(); + inspector.dispose(); } finally { controller.shutdown(ctx); } diff --git a/extensions/codex/src/inspector-view.test.ts b/extensions/codex/src/inspector-view.test.ts new file mode 100644 index 0000000..96b2f5d --- /dev/null +++ b/extensions/codex/src/inspector-view.test.ts @@ -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[0], + { selectedBg: 0 } as ConstructorParameters[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(); +}); diff --git a/extensions/codex/src/inspector-view.ts b/extensions/codex/src/inspector-view.ts new file mode 100644 index 0000000..e0a894a --- /dev/null +++ b/extensions/codex/src/inspector-view.ts @@ -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; + } +} diff --git a/extensions/codex/src/inspector.test.ts b/extensions/codex/src/inspector.test.ts new file mode 100644 index 0000000..7c372a3 --- /dev/null +++ b/extensions/codex/src/inspector.test.ts @@ -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(() => {}) } + : { message: Array.from({ length: 40 }, (_, index) => `Report row ${index}`).join("\n") }, + ); + let overlay: ReturnType | 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(); + } +}); diff --git a/extensions/codex/src/inspector.ts b/extensions/codex/src/inspector.ts new file mode 100644 index 0000000..a414353 --- /dev/null +++ b/extensions/codex/src/inspector.ts @@ -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; + readonly ui: Pick; +} + +export type InspectorSource = + | InspectorContent + | { + loading: string; + load: (signal: AbortSignal) => Promise; + /** 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 { + 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 { + // 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( + (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 { + 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?.(); + } +} diff --git a/extensions/codex/src/usage-controller.test.ts b/extensions/codex/src/usage-controller.test.ts index 52a42ec..713fefb 100644 --- a/extensions/codex/src/usage-controller.test.ts +++ b/extensions/codex/src/usage-controller.test.ts @@ -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(); 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(); 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(); 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(); 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(); @@ -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(); - 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(() => 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(); + 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(() => 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(); 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(); - 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(); + 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(); + 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(); 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() { @@ -390,16 +453,18 @@ function deferred() { 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(), - custom: surface.custom, - }, + ui: { notify: vi.fn(), 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(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) { diff --git a/extensions/codex/src/usage-controller.ts b/extensions/codex/src/usage-controller.ts index f3ded72..3aee597 100644 --- a/extensions/codex/src/usage-controller.ts +++ b/extensions/codex/src/usage-controller.ts @@ -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; - readonly ui: Pick; } -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 { 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 { - // 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( - (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 { - 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 { + 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, + }; + } } } diff --git a/extensions/codex/src/usage-sdk.test.ts b/extensions/codex/src/usage-sdk.test.ts index 76bc0f6..67c71a8 100644 --- a/extensions/codex/src/usage-sdk.test.ts +++ b/extensions/codex/src/usage-sdk.test.ts @@ -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(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(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(); + const accountRequest = vi.fn(() => 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(); + const nextResponse = deferred(); + const accountRequest = vi + .fn() + .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(); + const accountRequest = vi.fn(() => 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() { + let resolve!: (value: T) => void; + const promise = new Promise((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(async () => { throw new Error("Credential writes forbidden"); }); + const credentialReads = vi.fn(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) { @@ -191,6 +342,7 @@ async function openCodexExtensionSession(runtime: ModelRuntime, model: Model