Compare commits
13 Commits
615762b0ac
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 461773c677 | |||
| cf7ad047d3 | |||
| 26ba42f2b9 | |||
| 9951a11b29 | |||
| f8fac5fd45 | |||
| 7906519eeb | |||
| 35f8276a16 | |||
| 9c88e0a003 | |||
| 576f31b13a | |||
| 419c78c357 | |||
| c8c5de590d | |||
| 4e36542b8b | |||
| b035ab3d0c |
17
AGENTS.md
17
AGENTS.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
39
index.ts
39
index.ts
@@ -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;
|
||||||
|
if (rerenderScheduled) return;
|
||||||
|
rerenderScheduled = true;
|
||||||
|
|
||||||
|
// Debounce Render - Coalesce rapid rerender calls into a single pass.
|
||||||
|
queueMicrotask(() => {
|
||||||
|
rerenderScheduled = false;
|
||||||
requestRender?.();
|
requestRender?.();
|
||||||
if (ctx.hasUI) ctx.ui.setStatus("pi-statusbar", undefined);
|
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");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -60,8 +60,18 @@ function usageColor(fraction: number): { r: number; g: number; b: number } {
|
|||||||
|
|
||||||
function findWindowLimits(report: UsageReport): UsageWindows {
|
function findWindowLimits(report: UsageReport): UsageWindows {
|
||||||
const windowId = (limit: UsageLimit) => (limit.window?.id ?? limit.scope.windowId ?? "").toLowerCase();
|
const windowId = (limit: UsageLimit) => (limit.window?.id ?? limit.scope.windowId ?? "").toLowerCase();
|
||||||
const current = report.limits.find(limit => ["5h", "primary"].includes(windowId(limit))) ?? report.limits[0];
|
const limitId = (limit: UsageLimit) => (limit.id ?? "").toLowerCase();
|
||||||
const week = report.limits.find(limit => ["7d", "secondary"].includes(windowId(limit))) ?? report.limits.find(limit => limit !== current);
|
|
||||||
|
// Match by Window ID
|
||||||
|
const currentByWindow = report.limits.find(limit => ["5h", "primary"].includes(windowId(limit)));
|
||||||
|
const weekByWindow = report.limits.find(limit => ["7d", "secondary"].includes(windowId(limit)));
|
||||||
|
|
||||||
|
// Match by Limit ID (Z.ai Uses Generic "quota" Window IDs for Both Limits)
|
||||||
|
const currentByLimit = report.limits.find(limit => ["zai:tokens"].includes(limitId(limit)));
|
||||||
|
const weekByLimit = report.limits.find(limit => ["zai:requests"].includes(limitId(limit)));
|
||||||
|
|
||||||
|
const current = currentByWindow ?? currentByLimit ?? report.limits[0];
|
||||||
|
const week = weekByWindow ?? weekByLimit ?? report.limits.find(limit => limit !== current);
|
||||||
return { current, week };
|
return { current, week };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
33
render.ts
33
render.ts
@@ -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 {
|
||||||
|
|||||||
5
types.ts
5
types.ts
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user