/** * /usage — painel agregado de quota/consumo (KIMI + PI-CODE). * * - TUI (ctx.mode === "tui" && ctx.hasUI): painel rolável via ctx.ui.custom. * - RPC (hasUI): texto via notify. Print/JSON: texto simples via stdout. * - Cache em memória lazy (sem timers no factory — a factory só registra). * * O import de @earendil-works/pi-tui é DINÂMICO e só acontece no ramo TUI, * mantendo o módulo carregável em node puro (type-stripping) para testes. */ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; import { KIMI_MISSING_REASON, loadConfig, type UsagePanelConfig } from "./src/config.ts"; import { fetchKimiUsage, sanitize, type KimiResult } from "./src/kimi.ts"; import { caramel } from "./src/format.ts"; import { renderReportText, reportToJson, type UsageReport } from "./src/render.ts"; import { scanSessions } from "./src/scanner.ts"; interface CacheEntry { atMs: number; report: UsageReport; } async function collectReport(cfg: UsagePanelConfig, nowMs: number): Promise { // Falha de uma fonte NÃO derruba a outra. const kimiPromise: Promise = cfg.kimiApiKey ? fetchKimiUsage(cfg.kimiApiKey) : Promise.resolve({ available: false, reason: KIMI_MISSING_REASON }); const sessionsPromise = scanSessions(cfg.sessionsDir, nowMs, cfg.timeZone) .then( (scan): UsageReport["piCode"] => ({ available: true, sessionsDir: cfg.sessionsDir, aggregate: scan.aggregate, }), ) .catch( (err): UsageReport["piCode"] => ({ available: false, reason: `indisponível: ${err instanceof Error ? err.message : String(err)}`, }), ); const [kimi, piCode] = await Promise.all([kimiPromise, sessionsPromise]); return { generatedAtMs: nowMs, timeZone: cfg.timeZone, kimi, piCode }; } interface PanelTui { requestRender: () => void; } /** Painel rolável simples: renderiza `lines` numa janela com bordas. */ function createPanelComponent( lines: string[], matchesKey: (data: string, key: string) => boolean, truncateToWidth: (text: string, width: number, ellipsis?: string, pad?: boolean) => string, visibleWidth: (text: string) => number, tui: PanelTui, done: (value: null) => void, ) { let scrollOffset = 0; const maxVisible = (): number => { const rows = typeof process.stdout.rows === "number" ? process.stdout.rows : 24; return Math.max(6, rows - 8); }; // Painel encolhe para caber o conteúdo; só usa a tela toda quando excede. const visibleLines = (): number => Math.min(maxVisible(), Math.max(1, lines.length)); return { handleInput(data: string): void { const visible = visibleLines(); const maxOffset = Math.max(0, lines.length - visible); if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || data === "q") { done(null); } else if (matchesKey(data, "up")) { scrollOffset = Math.max(0, scrollOffset - 1); tui.requestRender(); } else if (matchesKey(data, "down")) { scrollOffset = Math.min(maxOffset, scrollOffset + 1); tui.requestRender(); } else if (matchesKey(data, "pageup")) { scrollOffset = Math.max(0, scrollOffset - visible); tui.requestRender(); } else if (matchesKey(data, "pagedown")) { scrollOffset = Math.min(maxOffset, scrollOffset + visible); tui.requestRender(); } }, render(width: number): string[] { const visible = visibleLines(); const innerW = Math.max(1, width - 4); const out: string[] = []; // Cor aplicada DEPOIS de todo cálculo de largura (ANSI fora das strings medidas). const title = " /usage — quota & consumo "; out.push(caramel(`╭─${title}${"─".repeat(Math.max(0, innerW + 1 - visibleWidth(title)))}╮`)); const slice = lines.slice(scrollOffset, scrollOffset + visible); for (const line of slice) out.push(`${caramel("│")} ${truncateToWidth(line, innerW, "…", true)} ${caramel("│")}`); for (let i = slice.length; i < visible; i++) out.push(`${caramel("│")} ${" ".repeat(innerW)} ${caramel("│")}`); const hiddenDown = Math.max(0, lines.length - visible - scrollOffset); const footer = ` ↑${scrollOffset} ↓${hiddenDown} · ↑↓/PgUp/PgDn rola · Esc/q fecha `; const clipped = truncateToWidth(footer, innerW, "…", true); out.push(caramel(`╰─${clipped}${"─".repeat(Math.max(0, innerW + 1 - visibleWidth(clipped)))}╯`)); return out; }, invalidate(): void {}, dispose(): void {}, }; } async function showPanel(ctx: ExtensionCommandContext, lines: string[]): Promise { const { matchesKey, truncateToWidth, visibleWidth } = await import("@earendil-works/pi-tui"); await ctx.ui.custom((tui, _theme, _keybindings, done) => createPanelComponent(lines, matchesKey, truncateToWidth, visibleWidth, tui, done), ); } export default function usagePanelExtension(pi: ExtensionAPI): void { // Factory enxuta: apenas registra o comando. Nada de timers/IO aqui. let cache: CacheEntry | null = null; async function getReport(refresh: boolean): Promise<{ report: UsageReport; kimiApiKey: string | undefined }> { const cfg = loadConfig(); const nowMs = Date.now(); if (!refresh && cache && nowMs - cache.atMs < cfg.cacheTtlMs) { return { report: cache.report, kimiApiKey: cfg.kimiApiKey }; } const report = await collectReport(cfg, nowMs); cache = { atMs: nowMs, report }; return { report, kimiApiKey: cfg.kimiApiKey }; } pi.registerCommand("usage", { description: "Painel agregado de quota/consumo (KIMI + PI-CODE). Opções: --json, --refresh", getArgumentCompletions: (prefix: string) => { const options = ["--json", "--refresh"].filter((o) => o.startsWith(prefix)); return options.length > 0 ? options.map((o) => ({ value: o, label: o })) : null; }, handler: async (args: string, ctx: ExtensionCommandContext) => { try { const flags = new Set(args.split(/\s+/).filter(Boolean)); const jsonMode = flags.has("--json"); const refresh = flags.has("--refresh"); if (ctx.hasUI) ctx.ui.notify("Coletando uso (KIMI + sessões)…", "info"); const { report, kimiApiKey } = await getReport(refresh); const text = jsonMode ? JSON.stringify(reportToJson(report, kimiApiKey), null, 2) : renderReportText(report).join("\n"); if (ctx.mode === "tui" && ctx.hasUI) { await showPanel(ctx, text.split("\n")); } else if (ctx.hasUI) { ctx.ui.notify(text, "info"); } else { console.log(text); } } catch (err) { // Falha inesperada: mensagem amigável, nunca stack crua nem a chave. const raw = err instanceof Error ? err.message : String(err); const msg = sanitize(`falha ao coletar uso: ${raw}`, loadConfig().kimiApiKey ?? ""); if (ctx.hasUI) { ctx.ui.notify(msg, "error"); } else { console.error(msg); process.exitCode = 1; } } }, }); }