Compare commits

...

12 Commits

Author SHA1 Message Date
cf7ad047d3 refactor: type ModuleContext and index.ts functions with SDK types 2026-05-03 21:28:15 -04:00
26ba42f2b9 docs: remove deleted usage/shared.ts from AGENTS.md layout 2026-05-03 21:25:43 -04:00
9951a11b29 fix: make overlay() ANSI-aware using visible character positions 2026-05-03 21:25:34 -04:00
f8fac5fd45 refactor: consolidate duplicate toNumber into utils.ts, remove usage/shared.ts 2026-05-03 21:25:12 -04:00
7906519eeb docs: update AGENTS.md with accurate file layout and current conventions 2026-05-03 21:22:26 -04:00
35f8276a16 fix: use same refresh interval for all providers
chore: update AGENTS.md to require explicit approval for usage/ and providers/ changes
2026-05-03 21:21:49 -04:00
9c88e0a003 perf: debounce rerender calls with queueMicrotask to coalesce rapid updates 2026-05-03 21:16:22 -04:00
576f31b13a fix: use floor for duration minutes to prevent 60m overflow 2026-05-03 21:16:04 -04:00
419c78c357 refactor: replace any types with proper SDK types in event handlers 2026-05-03 21:15:43 -04:00
c8c5de590d perf: cache ANSI prefix regex in truncate instead of re-creating per call 2026-05-03 21:14:07 -04:00
4e36542b8b fix: add explicit any types to resolve implicit any diagnostics 2026-05-03 21:13:10 -04:00
b035ab3d0c fix: keep old usage data visible during refresh to prevent pop-in 2026-05-03 21:11:57 -04:00
7 changed files with 66 additions and 45 deletions

View File

@@ -6,19 +6,21 @@
## Layout ## Layout
- `index.ts` — Extension entrypoint, footer registration, refresh timers, slash command, auth lookup. - `index.ts` — Extension entrypoint, footer registration, refresh timers, debounced rendering, slash command, auth lookup.
- `config.ts` — User-facing layout/config. Prefer changing this for layout tweaks. - `config.ts` — User-facing layout/config. Prefer changing this for layout tweaks.
- `types.ts` — Shared config/module/state types. - `types.ts` — Shared config/module/state types.
- `render.ts` — Width allocation, ANSI-aware truncation, dim text rendering. - `render.ts` — Width allocation, ANSI-aware truncation, dim text rendering.
- `utils.ts` — Shared utility helpers (`isRecord`, `toNumber`).
- `usage.ts` — Provider-agnostic usage type definitions (report, limit, credential, etc.).
- `modules/basic.ts` — Directory, context, model, thinking, and cost modules. - `modules/basic.ts` — Directory, context, model, thinking, and cost modules.
- `modules/usage.ts` — Usage bars/text modules and color gradient rendering. - `modules/usage.ts` — Usage bars/text modules and color gradient rendering.
- `usage/` — Provider usage fetchers adapted from `oh-my-pi`: https://github.com/can1357/oh-my-pi/tree/main/packages/ai/src/usage - `usage/` — Provider usage fetchers adapted from `oh-my-pi`: https://github.com/can1357/oh-my-pi/tree/main/packages/ai/src/usage
- `providers/openai-codex/constants.ts`, `usage.ts`, `utils.ts` — Local shims/support for copied usage code. - `providers/openai-codex/constants.ts` — OpenAI Codex base URL constant.
## Conventions ## Conventions
- Keep user layout changes in `config.ts` when possible. - Keep user layout changes in `config.ts` when possible.
- Preserve copied provider logic in `usage/` unless fixing integration issues. - **Do NOT modify files in `usage/` or `providers/` without explicit user approval.** These contain copied upstream provider logic. Always ask before changing them.
- Use composable module config instead of subtype strings. For usage, prefer: - Use composable module config instead of subtype strings. For usage, prefer:
```ts ```ts
@@ -26,14 +28,15 @@
{ type: "usage", window: "week", style: "text", parts: ["percent", "time"], separator: " | " } { type: "usage", window: "week", style: "text", parts: ["percent", "time"], separator: " | " }
``` ```
- Usage fallback behavior should be stable/no-pop-in: - Usage data is preserved during refresh (no clearing before fetch) to prevent pop-in:
- unknown/loading line bars render like 0% usage - Old data stays visible until new data arrives.
- unknown/loading percent renders `0%` - When no data exists yet: line bars render like 0% usage, percent renders `0%`, time renders `∞`.
- unknown/loading time renders `∞`
- Usage bar colors are a continuous green → yellow → red gradient derived from usage percentage, not hard thresholds. - Usage bar colors are a continuous green → yellow → red gradient derived from usage percentage, not hard thresholds.
- Rendering must remain ANSI-aware. If adding colors, update `visibleWidth`/`truncate` behavior as needed. - Rendering must remain ANSI-aware. If adding colors, update `visibleWidth`/`truncate` behavior as needed.
- Text modules and separators should remain dim; filled usage bars may use gradient color. - Text modules and separators should remain dim; filled usage bars may use gradient color.
- Thinking level comes from `pi.getThinkingLevel()` and `thinking_level_select`, not `ctx.thinkingLevel`. - Thinking level comes from `pi.getThinkingLevel()` and `thinking_level_select`, not `ctx.thinkingLevel`.
- `rerender()` is debounced via `queueMicrotask` — multiple rapid calls coalesce into a single render pass.
- All providers use the same refresh interval (`REFRESH_MS`).
## Testing ## Testing

View File

@@ -1,4 +1,4 @@
import { AuthStorage, type AuthCredential, type ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { AuthStorage, type AuthCredential, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext, type ReadonlyFooterDataProvider, type SessionStartEvent, type SessionShutdownEvent, type AgentEndEvent } from "@mariozechner/pi-coding-agent";
import { statusbarConfig } from "./config"; import { statusbarConfig } from "./config";
import { contextModule, costModule, directoryModule, modelModule, thinkingModule } from "./modules/basic"; import { contextModule, costModule, directoryModule, modelModule, thinkingModule } from "./modules/basic";
import { usageModule } from "./modules/usage"; import { usageModule } from "./modules/usage";
@@ -10,7 +10,6 @@ import type { ModuleContext, ModuleSpec, RenderedModule, StatusbarState } from "
import type { Provider, UsageCredential, UsageProvider, UsageReport } from "./usage"; import type { Provider, UsageCredential, UsageProvider, UsageReport } from "./usage";
const REFRESH_MS = 60_000; const REFRESH_MS = 60_000;
const ANTHROPIC_REFRESH_MS = 15 * 60_000;
const usageProviders: Record<Provider, UsageProvider> = { const usageProviders: Record<Provider, UsageProvider> = {
"openai-codex": openaiCodexUsageProvider, "openai-codex": openaiCodexUsageProvider,
@@ -22,7 +21,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); return typeof value === "object" && value !== null && !Array.isArray(value);
} }
function activeProvider(ctx: any): Provider | undefined { function activeProvider(ctx: ExtensionContext): Provider | undefined {
const provider = ctx.model?.provider as string | undefined; const provider = ctx.model?.provider as string | undefined;
if (provider === "openai-codex" || provider === "zai" || provider === "anthropic") return provider; if (provider === "openai-codex" || provider === "zai" || provider === "anthropic") return provider;
return undefined; return undefined;
@@ -92,7 +91,7 @@ function renderModule(moduleCtx: ModuleContext, spec: ModuleSpec): RenderedModul
} }
} }
function renderFooter(ctx: any, footerData: any, state: StatusbarState, width: number, theme?: any): string[] { function renderFooter(ctx: ExtensionContext, footerData: ReadonlyFooterDataProvider, state: StatusbarState, width: number, theme?: any): string[] {
const moduleCtx: ModuleContext = { ctx, footerData, state, config: statusbarConfig }; const moduleCtx: ModuleContext = { ctx, footerData, state, config: statusbarConfig };
return statusbarConfig.rows.map(row => renderRow({ return statusbarConfig.rows.map(row => renderRow({
left: (row.left ?? []).map(spec => renderModule(moduleCtx, spec)), left: (row.left ?? []).map(spec => renderModule(moduleCtx, spec)),
@@ -111,7 +110,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
let timer: ReturnType<typeof setInterval> | undefined; let timer: ReturnType<typeof setInterval> | undefined;
let inFlight: AbortController | undefined; let inFlight: AbortController | undefined;
let lastProvider: Provider | undefined; let lastProvider: Provider | undefined;
let latestCtx: any; let latestCtx: ExtensionContext | undefined;
let requestRender: (() => void) | undefined; let requestRender: (() => void) | undefined;
const statusbarState: StatusbarState = {}; const statusbarState: StatusbarState = {};
const authStorage = AuthStorage.create(); const authStorage = AuthStorage.create();
@@ -124,13 +123,22 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
} }
} }
function rerender(ctx: any) { let rerenderScheduled = false;
function rerender(ctx: ExtensionContext) {
latestCtx = ctx; latestCtx = ctx;
requestRender?.(); if (rerenderScheduled) return;
if (ctx.hasUI) ctx.ui.setStatus("pi-statusbar", undefined); rerenderScheduled = true;
// Debounce Render - Coalesce rapid rerender calls into a single pass.
queueMicrotask(() => {
rerenderScheduled = false;
requestRender?.();
const current = latestCtx;
if (current?.hasUI) current.ui.setStatus("pi-statusbar", undefined);
});
} }
function installFooter(ctx: any) { function installFooter(ctx: ExtensionContext) {
if (!ctx.hasUI) return; if (!ctx.hasUI) return;
latestCtx = ctx; latestCtx = ctx;
ctx.ui.setFooter((tui: any, theme: any, footerData: any) => { ctx.ui.setFooter((tui: any, theme: any, footerData: any) => {
@@ -147,7 +155,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
}); });
} }
async function refresh(ctx: any, force = false) { async function refresh(ctx: ExtensionContext, force = false) {
latestCtx = ctx; latestCtx = ctx;
const provider = activeProvider(ctx); const provider = activeProvider(ctx);
lastProvider = provider; lastProvider = provider;
@@ -158,7 +166,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
return; return;
} }
const minRefreshMs = provider === "anthropic" ? ANTHROPIC_REFRESH_MS : REFRESH_MS; const minRefreshMs = REFRESH_MS;
if (!force && statusbarState.report?.provider === provider && Date.now() - statusbarState.report.fetchedAt < minRefreshMs) { if (!force && statusbarState.report?.provider === provider && Date.now() - statusbarState.report.fetchedAt < minRefreshMs) {
rerender(ctx); rerender(ctx);
return; return;
@@ -175,9 +183,6 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
inFlight?.abort(); inFlight?.abort();
const controller = new AbortController(); const controller = new AbortController();
inFlight = controller; inFlight = controller;
statusbarState.report = undefined;
statusbarState.error = undefined;
rerender(ctx);
try { try {
let activeCredential = credential; let activeCredential = credential;
@@ -214,7 +219,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
if (lastProvider === provider) rerender(ctx); if (lastProvider === provider) rerender(ctx);
} }
pi.on("session_start", async (_event, ctx) => { pi.on("session_start", async (_event: SessionStartEvent, ctx: ExtensionContext) => {
updateThinkingLevel(); updateThinkingLevel();
installFooter(ctx); installFooter(ctx);
await refresh(ctx, true); await refresh(ctx, true);
@@ -223,7 +228,7 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
timer = setInterval(() => void refresh(latestCtx ?? ctx), REFRESH_MS); timer = setInterval(() => void refresh(latestCtx ?? ctx), REFRESH_MS);
}); });
pi.on("session_shutdown", async (_event, ctx) => { pi.on("session_shutdown", async (_event: SessionShutdownEvent, ctx: ExtensionContext) => {
if (timer) clearInterval(timer); if (timer) clearInterval(timer);
timer = undefined; timer = undefined;
inFlight?.abort(); inFlight?.abort();
@@ -245,14 +250,14 @@ export default function piStatusbarExtension(pi: ExtensionAPI) {
rerender(ctx); rerender(ctx);
}); });
pi.on("agent_end", async (_event, ctx) => { pi.on("agent_end", async (_event: AgentEndEvent, ctx: ExtensionContext) => {
updateThinkingLevel(); updateThinkingLevel();
await refresh(ctx); await refresh(ctx);
}); });
pi.registerCommand("refresh-usage", { pi.registerCommand("refresh-usage", {
description: "Refresh account usage for the active provider", description: "Refresh account usage for the active provider",
handler: async (_args, ctx) => { handler: async (_args: string, ctx: ExtensionCommandContext) => {
await refresh(ctx, true); await refresh(ctx, true);
ctx.ui.notify(usageSummary(statusbarState.report, statusbarState.error), statusbarState.error ? "warning" : "info"); ctx.ui.notify(usageSummary(statusbarState.report, statusbarState.error), statusbarState.error ? "warning" : "info");
}, },

View File

@@ -14,7 +14,7 @@ function duration(valueMs: number | undefined): string | undefined {
const ms = Math.max(0, valueMs); const ms = Math.max(0, valueMs);
const days = Math.floor(ms / 86_400_000); const days = Math.floor(ms / 86_400_000);
const hours = Math.floor((ms % 86_400_000) / 3_600_000); const hours = Math.floor((ms % 86_400_000) / 3_600_000);
const minutes = Math.round((ms % 3_600_000) / 60_000); const minutes = Math.floor((ms % 3_600_000) / 60_000);
if (days > 0) return `${days}d${hours}h`; if (days > 0) return `${days}d${hours}h`;
if (hours > 0) return `${hours}h${minutes}m`; if (hours > 0) return `${hours}h${minutes}m`;
return `${minutes}m`; return `${minutes}m`;

View File

@@ -2,6 +2,7 @@ import type { ModuleSpec, RenderedModule } from "./types";
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
const ANSI_RE = new RegExp("\\u001b\\[[0-9;]*m", "g"); const ANSI_RE = new RegExp("\\u001b\\[[0-9;]*m", "g");
const ANSI_PREFIX_RE = /^\u001b\[[0-9;]*m/;
export function visibleWidth(text: string): number { export function visibleWidth(text: string): number {
return text.replace(ANSI_RE, "").length; return text.replace(ANSI_RE, "").length;
@@ -16,8 +17,7 @@ export function truncate(text: string, width: number): string {
let count = 0; let count = 0;
for (let index = 0; index < text.length && count < width; index++) { for (let index = 0; index < text.length && count < width; index++) {
if (text[index] === "\x1b") { if (text[index] === "\x1b") {
// eslint-disable-next-line no-control-regex const match = text.slice(index).match(ANSI_PREFIX_RE);
const match = text.slice(index).match(new RegExp("^\\u001b\\[[0-9;]*m"));
if (match) { if (match) {
output += match[0]; output += match[0];
index += match[0].length - 1; index += match[0].length - 1;
@@ -34,10 +34,33 @@ export function joinText(parts: string[], separator: string): string {
return parts.filter(Boolean).join(` ${separator} `); return parts.filter(Boolean).join(` ${separator} `);
} }
// ANSI-Aware Slice - Extract a substring by visible character positions,
// preserving ANSI escape codes that precede visible characters.
function ansiSlice(text: string, start: number, end: number): string {
let output = "";
let visible = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === "\x1b") {
const match = text.slice(i).match(ANSI_PREFIX_RE);
if (match) {
if (visible >= start && visible < end) output += match[0];
i += match[0].length - 1;
continue;
}
}
if (visible >= start && visible < end) output += text[i];
visible++;
if (visible >= end) break;
}
return output;
}
export function overlay(base: string, text: string): string { export function overlay(base: string, text: string): string {
if (visibleWidth(text) >= visibleWidth(base)) return truncate(text, visibleWidth(base)); const baseWidth = visibleWidth(base);
const start = Math.max(0, Math.floor((visibleWidth(base) - visibleWidth(text)) / 2)); const textWidth = visibleWidth(text);
return `${base.slice(0, start)}${text}${base.slice(start + visibleWidth(text))}`; if (textWidth >= baseWidth) return truncate(text, baseWidth);
const start = Math.max(0, Math.floor((baseWidth - textWidth) / 2));
return `${ansiSlice(base, 0, start)}${text}${ansiSlice(base, start + textWidth, baseWidth)}`;
} }
function dimText(text: string, theme?: any): string { function dimText(text: string, theme?: any): string {

View File

@@ -1,3 +1,4 @@
import type { ExtensionContext, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
import type { UsageLimit, UsageReport } from "./usage"; import type { UsageLimit, UsageReport } from "./usage";
export type ModuleName = "directory" | "context" | "model" | "thinking" | "cost" | "usage"; export type ModuleName = "directory" | "context" | "model" | "thinking" | "cost" | "usage";
@@ -40,8 +41,8 @@ export interface StatusbarState {
} }
export interface ModuleContext { export interface ModuleContext {
ctx: any; ctx: ExtensionContext;
footerData: any; footerData: ReadonlyFooterDataProvider;
state: StatusbarState; state: StatusbarState;
config: StatusbarConfig; config: StatusbarConfig;
} }

View File

@@ -10,8 +10,7 @@ import type {
UsageReport, UsageReport,
UsageWindow, UsageWindow,
} from "../usage"; } from "../usage";
import { isRecord } from "../utils"; import { isRecord, toNumber } from "../utils";
import { toNumber } from "./shared";
const CODEX_USAGE_PATH = "wham/usage"; const CODEX_USAGE_PATH = "wham/usage";
const JWT_AUTH_CLAIM = "https://api.openai.com/auth"; const JWT_AUTH_CLAIM = "https://api.openai.com/auth";

View File

@@ -1,10 +0,0 @@
export const toNumber = (value: unknown): number | undefined => {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const trimmed = value.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
};