fuck sub bar
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"lastChangelogVersion": "0.57.1",
|
||||
"defaultProvider": "opencode-go",
|
||||
"defaultModel": "kimi-k2.5",
|
||||
"defaultProvider": "openrouter",
|
||||
"defaultModel": "openai/gpt-5.3-codex",
|
||||
"defaultThinkingLevel": "medium",
|
||||
"theme": "matugen",
|
||||
"lsp": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-2718
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"name": "sub-bar",
|
||||
"version": "1.3.0",
|
||||
"description": "Usage widget extension for pi-coding-agent",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts",
|
||||
"./sub-core/index.ts"
|
||||
]
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Re-export shared types and metadata from src/shared.
|
||||
* Files in src/settings/, src/providers/, src/usage/ import "../../shared.js"
|
||||
* which resolves here.
|
||||
*/
|
||||
export * from "./src/shared.js";
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Core settings fallbacks for sub-bar when sub-core settings are unavailable.
|
||||
*/
|
||||
|
||||
import type { CoreSettings } from "./shared.js";
|
||||
import type { Settings } from "./settings-types.js";
|
||||
import { PROVIDERS, PROVIDER_METADATA } from "./providers/metadata.js";
|
||||
|
||||
export function getFallbackCoreSettings(settings: Settings): CoreSettings {
|
||||
const providers = {} as CoreSettings["providers"];
|
||||
for (const provider of PROVIDERS) {
|
||||
providers[provider] = {
|
||||
enabled: "auto",
|
||||
fetchStatus: Boolean(PROVIDER_METADATA[provider]?.status),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
behavior: settings.behavior,
|
||||
statusRefresh: settings.statusRefresh ?? settings.behavior,
|
||||
providerOrder: settings.providerOrder,
|
||||
defaultProvider: settings.defaultProvider ?? null,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { Theme, ThemeColor } from "@mariozechner/pi-coding-agent";
|
||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||
import type { DividerCharacter } from "./settings-types.js";
|
||||
|
||||
const ANSI_REGEX = /\x1b\[[0-9;]*m/g;
|
||||
const SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
const DIVIDER_JOIN_MAP: Partial<Record<DividerCharacter, { top: string; bottom: string; line: string }>> = {
|
||||
"|": { top: "┬", bottom: "┴", line: "─" },
|
||||
"│": { top: "┬", bottom: "┴", line: "─" },
|
||||
"┆": { top: "┬", bottom: "┴", line: "─" },
|
||||
"┃": { top: "┳", bottom: "┻", line: "━" },
|
||||
"┇": { top: "┳", bottom: "┻", line: "━" },
|
||||
"║": { top: "╦", bottom: "╩", line: "═" },
|
||||
};
|
||||
|
||||
export function buildDividerLine(
|
||||
width: number,
|
||||
baseLine: string,
|
||||
dividerChar: DividerCharacter,
|
||||
joinEnabled: boolean,
|
||||
position: "top" | "bottom",
|
||||
dividerColor: ThemeColor,
|
||||
theme: Theme
|
||||
): string {
|
||||
let lineChar = "─";
|
||||
let joinChar: string | undefined;
|
||||
if (joinEnabled) {
|
||||
const joinInfo = DIVIDER_JOIN_MAP[dividerChar];
|
||||
if (joinInfo) {
|
||||
lineChar = joinInfo.line;
|
||||
joinChar = position === "top" ? joinInfo.top : joinInfo.bottom;
|
||||
}
|
||||
}
|
||||
const lineChars = Array.from(lineChar.repeat(Math.max(1, width)));
|
||||
if (joinChar) {
|
||||
const stripped = baseLine.replace(ANSI_REGEX, "");
|
||||
let pos = 0;
|
||||
for (const { segment } of SEGMENTER.segment(stripped)) {
|
||||
if (pos >= lineChars.length) break;
|
||||
if (segment === dividerChar) {
|
||||
lineChars[pos] = joinChar;
|
||||
}
|
||||
pos += Math.max(1, visibleWidth(segment));
|
||||
}
|
||||
}
|
||||
return theme.fg(dividerColor, lineChars.join(""));
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Error utilities for the sub-bar extension
|
||||
*/
|
||||
|
||||
import type { UsageError, UsageErrorCode } from "./types.js";
|
||||
|
||||
export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError {
|
||||
return { code, message, httpStatus };
|
||||
}
|
||||
|
||||
export function noCredentials(): UsageError {
|
||||
return createError("NO_CREDENTIALS", "No credentials found");
|
||||
}
|
||||
|
||||
export function noCli(cliName: string): UsageError {
|
||||
return createError("NO_CLI", `${cliName} CLI not found`);
|
||||
}
|
||||
|
||||
export function notLoggedIn(): UsageError {
|
||||
return createError("NOT_LOGGED_IN", "Not logged in");
|
||||
}
|
||||
|
||||
export function fetchFailed(reason?: string): UsageError {
|
||||
return createError("FETCH_FAILED", reason ?? "Fetch failed");
|
||||
}
|
||||
|
||||
export function httpError(status: number): UsageError {
|
||||
return createError("HTTP_ERROR", `HTTP ${status}`, status);
|
||||
}
|
||||
|
||||
export function apiError(message: string): UsageError {
|
||||
return createError("API_ERROR", message);
|
||||
}
|
||||
|
||||
export function timeout(): UsageError {
|
||||
return createError("TIMEOUT", "Request timed out");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should be considered "no data available" vs actual error
|
||||
* These are expected states when provider isn't configured
|
||||
*/
|
||||
export function isExpectedMissingData(error: UsageError): boolean {
|
||||
const ignoreCodes = new Set<UsageErrorCode>(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]);
|
||||
return ignoreCodes.has(error.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for display in the usage widget
|
||||
*/
|
||||
export function formatErrorForDisplay(error: UsageError): string {
|
||||
switch (error.code) {
|
||||
case "NO_CREDENTIALS":
|
||||
return "No creds";
|
||||
case "NO_CLI":
|
||||
return "No CLI";
|
||||
case "NOT_LOGGED_IN":
|
||||
return "Not logged in";
|
||||
case "HTTP_ERROR":
|
||||
if (error.httpStatus === 401) {
|
||||
return "token no longer valid – please /login again";
|
||||
}
|
||||
return `${error.httpStatus}`;
|
||||
case "FETCH_FAILED":
|
||||
case "API_ERROR":
|
||||
case "TIMEOUT":
|
||||
case "UNKNOWN":
|
||||
default:
|
||||
return "Fetch failed";
|
||||
}
|
||||
}
|
||||
@@ -1,937 +0,0 @@
|
||||
/**
|
||||
* UI formatting utilities for the sub-bar extension
|
||||
*/
|
||||
|
||||
import type { Theme } from "@mariozechner/pi-coding-agent";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import type { RateWindow, UsageSnapshot, ProviderStatus, ModelInfo } from "./types.js";
|
||||
import type {
|
||||
BaseTextColor,
|
||||
BarStyle,
|
||||
BarType,
|
||||
BarCharacter,
|
||||
BarWidth,
|
||||
ColorScheme,
|
||||
DividerBlanks,
|
||||
ResetTimerContainment,
|
||||
Settings,
|
||||
} from "./settings-types.js";
|
||||
import { isBackgroundColor, resolveBaseTextColor, resolveDividerColor } from "./settings-types.js";
|
||||
import { formatErrorForDisplay, isExpectedMissingData } from "./errors.js";
|
||||
import { getStatusIcon, getStatusLabel } from "./status.js";
|
||||
import { shouldShowWindow } from "./providers/windows.js";
|
||||
import { getUsageExtras } from "./providers/extras.js";
|
||||
import { normalizeTokens } from "./utils.js";
|
||||
|
||||
export interface UsageWindowParts {
|
||||
label: string;
|
||||
bar: string;
|
||||
pct: string;
|
||||
reset: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context window usage info from the pi framework
|
||||
*/
|
||||
export interface ContextInfo {
|
||||
tokens: number;
|
||||
contextWindow: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
type ModelInput = ModelInfo | string | undefined;
|
||||
|
||||
function resolveModelInfo(model?: ModelInput): ModelInfo | undefined {
|
||||
if (!model) return undefined;
|
||||
return typeof model === "string" ? { id: model } : model;
|
||||
}
|
||||
|
||||
function isCodexSparkModel(model?: ModelInput): boolean {
|
||||
const tokens = normalizeTokens(typeof model === "string" ? model : model?.id ?? "");
|
||||
return tokens.includes("codex") && tokens.includes("spark");
|
||||
}
|
||||
|
||||
function isCodexSparkWindow(window: RateWindow): boolean {
|
||||
const tokens = normalizeTokens(window.label ?? "");
|
||||
return tokens.includes("codex") && tokens.includes("spark");
|
||||
}
|
||||
|
||||
function getDisplayWindowLabel(window: RateWindow, model?: ModelInput): string {
|
||||
if (!isCodexSparkWindow(window)) return window.label;
|
||||
if (!isCodexSparkModel(model)) return window.label;
|
||||
const parts = window.label.trim().split(/\s+/);
|
||||
const suffix = parts.at(-1) ?? "";
|
||||
if (/^\d+h$/i.test(suffix) || /^day$/i.test(suffix) || /^week$/i.test(suffix)) {
|
||||
return suffix;
|
||||
}
|
||||
return window.label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the characters to use for progress bars
|
||||
*/
|
||||
function getBarCharacters(barCharacter: BarCharacter): { filled: string; empty: string } {
|
||||
let filled = "━";
|
||||
let empty = "━";
|
||||
switch (barCharacter) {
|
||||
case "light":
|
||||
filled = "─";
|
||||
empty = "─";
|
||||
break;
|
||||
case "heavy":
|
||||
filled = "━";
|
||||
empty = "━";
|
||||
break;
|
||||
case "double":
|
||||
filled = "═";
|
||||
empty = "═";
|
||||
break;
|
||||
case "block":
|
||||
filled = "█";
|
||||
empty = "█";
|
||||
break;
|
||||
default: {
|
||||
const raw = String(barCharacter);
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return { filled, empty };
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
const segments = Array.from(segmenter.segment(raw), (entry) => entry.segment);
|
||||
const first = segments[0] ?? trimmed[0] ?? "━";
|
||||
const second = segments[1];
|
||||
filled = first;
|
||||
empty = second ?? first;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { filled, empty };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color based on percentage and color scheme
|
||||
*/
|
||||
function getUsageColor(
|
||||
percent: number,
|
||||
isRemaining: boolean,
|
||||
colorScheme: ColorScheme,
|
||||
errorThreshold: number = 25,
|
||||
warningThreshold: number = 50,
|
||||
successThreshold: number = 75
|
||||
): "error" | "warning" | "base" | "success" {
|
||||
if (colorScheme === "monochrome") {
|
||||
return "base";
|
||||
}
|
||||
|
||||
// For remaining percentage (Codex style), invert the logic
|
||||
const effectivePercent = isRemaining ? percent : 100 - percent;
|
||||
|
||||
if (colorScheme === "success-base-warning-error") {
|
||||
// >75%: success, >50%: base, >25%: warning, <=25%: error
|
||||
if (effectivePercent < errorThreshold) return "error";
|
||||
if (effectivePercent < warningThreshold) return "warning";
|
||||
if (effectivePercent < successThreshold) return "base";
|
||||
return "success";
|
||||
}
|
||||
|
||||
// base-warning-error (default)
|
||||
// >50%: base, >25%: warning, <=25%: error
|
||||
if (effectivePercent < errorThreshold) return "error";
|
||||
if (effectivePercent < warningThreshold) return "warning";
|
||||
return "base";
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function getStatusColor(
|
||||
indicator: NonNullable<UsageSnapshot["status"]>["indicator"],
|
||||
colorScheme: ColorScheme
|
||||
): "error" | "warning" | "success" | "base" {
|
||||
if (colorScheme === "monochrome") {
|
||||
return "base";
|
||||
}
|
||||
if (indicator === "minor" || indicator === "maintenance") {
|
||||
return "warning";
|
||||
}
|
||||
if (indicator === "major" || indicator === "critical") {
|
||||
return "error";
|
||||
}
|
||||
if (indicator === "none") {
|
||||
return colorScheme === "success-base-warning-error" ? "success" : "base";
|
||||
}
|
||||
return "base";
|
||||
}
|
||||
|
||||
function resolveStatusTintColor(
|
||||
color: "error" | "warning" | "success" | "base",
|
||||
baseTextColor: BaseTextColor
|
||||
): BaseTextColor {
|
||||
return color === "base" ? baseTextColor : color;
|
||||
}
|
||||
|
||||
function fgFromBgAnsi(ansi: string): string {
|
||||
return ansi.replace(/\x1b\[48;/g, "\x1b[38;").replace(/\x1b\[49m/g, "\x1b[39m");
|
||||
}
|
||||
|
||||
function applyBaseTextColor(theme: Theme, color: BaseTextColor, text: string): string {
|
||||
if (isBackgroundColor(color)) {
|
||||
const fgAnsi = fgFromBgAnsi(theme.getBgAnsi(color as Parameters<Theme["getBgAnsi"]>[0]));
|
||||
return `${fgAnsi}${text}\x1b[39m`;
|
||||
}
|
||||
return theme.fg(resolveDividerColor(color), text);
|
||||
}
|
||||
|
||||
function resolveUsageColorTargets(settings?: Settings): {
|
||||
title: boolean;
|
||||
timer: boolean;
|
||||
bar: boolean;
|
||||
usageLabel: boolean;
|
||||
status: boolean;
|
||||
} {
|
||||
const targets = settings?.display.usageColorTargets;
|
||||
return {
|
||||
title: targets?.title ?? true,
|
||||
timer: targets?.timer ?? true,
|
||||
bar: targets?.bar ?? true,
|
||||
usageLabel: targets?.usageLabel ?? true,
|
||||
status: targets?.status ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function formatElapsedSince(timestamp: number): string {
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 60000) {
|
||||
const seconds = Math.max(1, Math.floor(diffMs / 1000));
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
const mins = diffMins % 60;
|
||||
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
const remHours = hours % 24;
|
||||
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
|
||||
}
|
||||
|
||||
const RESET_CONTAINMENT_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
function wrapResetContainment(text: string, containment: ResetTimerContainment): { wrapped: string; attachWithSpace: boolean } {
|
||||
switch (containment) {
|
||||
case "none":
|
||||
return { wrapped: text, attachWithSpace: true };
|
||||
case "blank":
|
||||
return { wrapped: text, attachWithSpace: true };
|
||||
case "[]":
|
||||
return { wrapped: `[${text}]`, attachWithSpace: true };
|
||||
case "<>":
|
||||
return { wrapped: `<${text}>`, attachWithSpace: true };
|
||||
case "()":
|
||||
return { wrapped: `(${text})`, attachWithSpace: true };
|
||||
default: {
|
||||
const trimmed = String(containment).trim();
|
||||
if (!trimmed) return { wrapped: `(${text})`, attachWithSpace: true };
|
||||
const segments = Array.from(RESET_CONTAINMENT_SEGMENTER.segment(trimmed), (entry) => entry.segment)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
if (segments.length === 0) return { wrapped: `(${text})`, attachWithSpace: true };
|
||||
const left = segments[0];
|
||||
const right = segments[1] ?? left;
|
||||
return { wrapped: `${left}${text}${right}`, attachWithSpace: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatResetDateTime(resetAt: string): string {
|
||||
const date = new Date(resetAt);
|
||||
if (Number.isNaN(date.getTime())) return resetAt;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getBarTypeLevels(barType: BarType): string[] | null {
|
||||
switch (barType) {
|
||||
case "horizontal-single":
|
||||
return ["▏", "▎", "▍", "▌", "▋", "▊", "▉", "█"];
|
||||
case "vertical":
|
||||
return ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
||||
case "braille":
|
||||
return ["⡀", "⡄", "⣄", "⣆", "⣇", "⣧", "⣷", "⣿"];
|
||||
case "shade":
|
||||
return ["░", "▒", "▓", "█"];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBarSegments(
|
||||
percent: number,
|
||||
width: number,
|
||||
levels: string[],
|
||||
options?: { allowMinimum?: boolean; emptyChar?: string }
|
||||
): { segments: Array<{ char: string; filled: boolean }>; minimal: boolean } {
|
||||
const totalUnits = Math.max(1, width) * levels.length;
|
||||
let filledUnits = Math.round((percent / 100) * totalUnits);
|
||||
let minimal = false;
|
||||
if (options?.allowMinimum && percent > 0 && filledUnits === 0) {
|
||||
filledUnits = 1;
|
||||
minimal = true;
|
||||
}
|
||||
const emptyChar = options?.emptyChar ?? " ";
|
||||
const segments: Array<{ char: string; filled: boolean }> = [];
|
||||
for (let i = 0; i < Math.max(1, width); i++) {
|
||||
if (filledUnits >= levels.length) {
|
||||
segments.push({ char: levels[levels.length - 1], filled: true });
|
||||
filledUnits -= levels.length;
|
||||
continue;
|
||||
}
|
||||
if (filledUnits > 0) {
|
||||
segments.push({ char: levels[Math.min(levels.length - 1, filledUnits - 1)], filled: true });
|
||||
filledUnits = 0;
|
||||
continue;
|
||||
}
|
||||
segments.push({ char: emptyChar, filled: false });
|
||||
}
|
||||
return { segments, minimal };
|
||||
}
|
||||
|
||||
function formatProviderLabel(theme: Theme, usage: UsageSnapshot, settings?: Settings, model?: ModelInput): string {
|
||||
const showProviderName = settings?.display.showProviderName ?? true;
|
||||
const showStatus = settings?.providers[usage.provider]?.showStatus ?? true;
|
||||
const error = usage.error;
|
||||
const fetchError = Boolean(error && !isExpectedMissingData(error));
|
||||
const baseStatus = showStatus ? usage.status : undefined;
|
||||
const lastSuccessAt = usage.lastSuccessAt;
|
||||
const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined;
|
||||
const fetchDescription = elapsed
|
||||
? (elapsed === "just now" ? "Last upd.: just now" : `Last upd.: ${elapsed} ago`)
|
||||
: "Fetch failed";
|
||||
const fetchStatus: ProviderStatus | undefined = fetchError
|
||||
? { indicator: "minor", description: fetchDescription }
|
||||
: undefined;
|
||||
const status = showStatus ? (fetchStatus ?? baseStatus) : undefined;
|
||||
const statusDismissOk = settings?.display.statusDismissOk ?? true;
|
||||
const statusModeRaw = settings?.display.statusIndicatorMode ?? "icon";
|
||||
const statusMode = statusModeRaw === "icon" || statusModeRaw === "text" || statusModeRaw === "icon+text"
|
||||
? statusModeRaw
|
||||
: "icon";
|
||||
const statusIconPack = settings?.display.statusIconPack ?? "emoji";
|
||||
const statusIconCustom = settings?.display.statusIconCustom;
|
||||
const providerLabelSetting = settings?.display.providerLabel ?? "none";
|
||||
const showColon = settings?.display.providerLabelColon ?? true;
|
||||
const boldProviderLabel = settings?.display.providerLabelBold ?? false;
|
||||
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
||||
const usageTargets = resolveUsageColorTargets(settings);
|
||||
|
||||
const statusActive = Boolean(status && (!statusDismissOk || status.indicator !== "none"));
|
||||
const showIcon = statusActive && (statusMode === "icon" || statusMode === "icon+text");
|
||||
const showText = statusActive && (statusMode === "text" || statusMode === "icon+text");
|
||||
|
||||
const labelSuffix = providerLabelSetting === "plan"
|
||||
? "Plan"
|
||||
: providerLabelSetting === "subscription"
|
||||
? "Subscription"
|
||||
: providerLabelSetting === "sub"
|
||||
? "Sub."
|
||||
: providerLabelSetting === "none"
|
||||
? ""
|
||||
: String(providerLabelSetting);
|
||||
|
||||
const rawName = usage.displayName?.trim() ?? "";
|
||||
const baseName = rawName.replace(/\s+(plan|subscription|sub\.?)[\s]*$/i, "").trim();
|
||||
const resolvedProviderName = baseName || rawName;
|
||||
const isSpark = usage.provider === "codex" && isCodexSparkModel(model);
|
||||
const providerName = isSpark ? `${resolvedProviderName} (Spark)` : resolvedProviderName;
|
||||
const providerLabel = showProviderName
|
||||
? [providerName, labelSuffix].filter(Boolean).join(" ")
|
||||
: "";
|
||||
const providerLabelWithColon = providerLabel && showColon ? `${providerLabel}:` : providerLabel;
|
||||
|
||||
const icon = showIcon && status ? getStatusIcon(status, statusIconPack, statusIconCustom) : "";
|
||||
const statusText = showText && status ? getStatusLabel(status) : "";
|
||||
const rawStatusColor = status
|
||||
? getStatusColor(status.indicator, settings?.display.colorScheme ?? "base-warning-error")
|
||||
: "base";
|
||||
const statusTint = usageTargets.status
|
||||
? resolveStatusTintColor(rawStatusColor, baseTextColor)
|
||||
: baseTextColor;
|
||||
const statusColor = statusTint;
|
||||
const dividerEnabled = settings?.display.statusProviderDivider ?? false;
|
||||
const dividerChar = settings?.display.dividerCharacter ?? "│";
|
||||
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
|
||||
const dividerGlyph = dividerChar === "none"
|
||||
? ""
|
||||
: dividerChar === "blank"
|
||||
? " "
|
||||
: dividerChar;
|
||||
|
||||
const statusParts: string[] = [];
|
||||
if (icon) statusParts.push(applyBaseTextColor(theme, statusColor, icon));
|
||||
if (statusText) statusParts.push(applyBaseTextColor(theme, statusColor, statusText));
|
||||
|
||||
const parts: string[] = [];
|
||||
if (statusParts.length > 0) {
|
||||
parts.push(statusParts.join(" "));
|
||||
}
|
||||
if (providerLabelWithColon) {
|
||||
if (statusParts.length > 0 && dividerEnabled && dividerGlyph) {
|
||||
parts.push(theme.fg(dividerColor, dividerGlyph));
|
||||
}
|
||||
const colored = applyBaseTextColor(theme, baseTextColor, providerLabelWithColon);
|
||||
parts.push(boldProviderLabel ? theme.bold(colored) : colored);
|
||||
}
|
||||
if (parts.length === 0) return "";
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a single usage window as a styled string
|
||||
*/
|
||||
export function formatUsageWindow(
|
||||
theme: Theme,
|
||||
window: RateWindow,
|
||||
isCodex: boolean,
|
||||
settings?: Settings,
|
||||
usage?: UsageSnapshot,
|
||||
options?: { useNormalColors?: boolean; barWidthOverride?: number },
|
||||
model?: ModelInput
|
||||
): string {
|
||||
const parts = formatUsageWindowParts(theme, window, isCodex, settings, usage, options, model);
|
||||
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
||||
const usageTargets = resolveUsageColorTargets(settings);
|
||||
|
||||
// Special handling for Extra usage label
|
||||
if (window.label.startsWith("Extra [")) {
|
||||
const match = window.label.match(/^(Extra \[)(on|active)(\] .*)$/);
|
||||
if (match) {
|
||||
const [, prefix, status, suffix] = match;
|
||||
const styledLabel =
|
||||
status === "active"
|
||||
? applyBaseTextColor(theme, baseTextColor, prefix)
|
||||
+ theme.fg("text", status)
|
||||
+ applyBaseTextColor(theme, baseTextColor, suffix)
|
||||
: applyBaseTextColor(theme, baseTextColor, window.label);
|
||||
const extraParts = [styledLabel, parts.bar, parts.pct].filter(Boolean);
|
||||
return extraParts.join(" ");
|
||||
}
|
||||
if (!usageTargets.title) {
|
||||
const extraParts = [applyBaseTextColor(theme, baseTextColor, window.label), parts.bar, parts.pct].filter(Boolean);
|
||||
return extraParts.join(" ");
|
||||
}
|
||||
const extraColor = getUsageColor(window.usedPercent, false, settings?.display.colorScheme ?? "base-warning-error");
|
||||
const extraTextColor = (options?.useNormalColors && extraColor === "base")
|
||||
? "text"
|
||||
: extraColor === "base"
|
||||
? baseTextColor
|
||||
: extraColor;
|
||||
const extraParts = [applyBaseTextColor(theme, extraTextColor, window.label), parts.bar, parts.pct].filter(Boolean);
|
||||
return extraParts.join(" ");
|
||||
}
|
||||
|
||||
const joinedParts = [parts.label, parts.bar, parts.pct, parts.reset].filter(Boolean);
|
||||
return joinedParts.join(" ");
|
||||
}
|
||||
|
||||
export function formatUsageWindowParts(
|
||||
theme: Theme,
|
||||
window: RateWindow,
|
||||
isCodex: boolean,
|
||||
settings?: Settings,
|
||||
usage?: UsageSnapshot,
|
||||
options?: { useNormalColors?: boolean; barWidthOverride?: number },
|
||||
model?: ModelInput
|
||||
): UsageWindowParts {
|
||||
const barStyle: BarStyle = settings?.display.barStyle ?? "both";
|
||||
const barWidthSetting = settings?.display.barWidth;
|
||||
const containBar = settings?.display.containBar ?? false;
|
||||
const barWidth = options?.barWidthOverride ?? (typeof barWidthSetting === "number" ? barWidthSetting : 6);
|
||||
const barType: BarType = settings?.display.barType ?? "horizontal-bar";
|
||||
const brailleFillEmpty = settings?.display.brailleFillEmpty ?? false;
|
||||
const brailleFullBlocks = settings?.display.brailleFullBlocks ?? false;
|
||||
const barCharacter: BarCharacter = settings?.display.barCharacter ?? "heavy";
|
||||
const colorScheme: ColorScheme = settings?.display.colorScheme ?? "base-warning-error";
|
||||
const resetTimePosition = settings?.display.resetTimePosition ?? "front";
|
||||
const resetTimeFormat = settings?.display.resetTimeFormat ?? "relative";
|
||||
const showUsageLabels = settings?.display.showUsageLabels ?? true;
|
||||
const showWindowTitle = settings?.display.showWindowTitle ?? true;
|
||||
const boldWindowTitle = settings?.display.boldWindowTitle ?? false;
|
||||
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
||||
const errorThreshold = settings?.display.errorThreshold ?? 25;
|
||||
const warningThreshold = settings?.display.warningThreshold ?? 50;
|
||||
const successThreshold = settings?.display.successThreshold ?? 75;
|
||||
|
||||
const rawUsedPct = Math.round(window.usedPercent);
|
||||
const usedPct = clampPercent(rawUsedPct);
|
||||
const displayPct = isCodex ? clampPercent(100 - usedPct) : usedPct;
|
||||
const isRemaining = isCodex;
|
||||
|
||||
const barPercent = clampPercent(displayPct);
|
||||
const filled = Math.round((barPercent / 100) * barWidth);
|
||||
const empty = Math.max(0, barWidth - filled);
|
||||
|
||||
const baseColor = getUsageColor(displayPct, isRemaining, colorScheme, errorThreshold, warningThreshold, successThreshold);
|
||||
const usageTargets = resolveUsageColorTargets(settings);
|
||||
const usageTextColor = (options?.useNormalColors && baseColor === "base")
|
||||
? "text"
|
||||
: baseColor === "base"
|
||||
? baseTextColor
|
||||
: baseColor;
|
||||
const neutralTextColor = options?.useNormalColors ? "text" : baseTextColor;
|
||||
const titleColor = usageTargets.title ? usageTextColor : neutralTextColor;
|
||||
const timerColor = usageTargets.timer ? usageTextColor : neutralTextColor;
|
||||
const usageLabelColor = usageTargets.usageLabel ? usageTextColor : neutralTextColor;
|
||||
const barUsageColor = (options?.useNormalColors && baseColor === "base") ? "text" : baseColor === "base" ? "muted" : baseColor;
|
||||
const neutralBarColor = baseTextColor === "dim" ? "dim" : "muted";
|
||||
const barColor = usageTargets.bar ? barUsageColor : neutralBarColor;
|
||||
const { filled: filledChar, empty: emptyChar } = getBarCharacters(barCharacter);
|
||||
|
||||
const emptyColor = "dim";
|
||||
|
||||
let barStr = "";
|
||||
if ((barStyle === "bar" || barStyle === "both") && barWidth > 0) {
|
||||
let levels = getBarTypeLevels(barType);
|
||||
if (barType === "braille" && brailleFullBlocks) {
|
||||
levels = ["⣿"];
|
||||
}
|
||||
if (!levels || barType === "horizontal-bar") {
|
||||
const filledCharWidth = Math.max(1, visibleWidth(filledChar));
|
||||
const emptyCharWidth = Math.max(1, visibleWidth(emptyChar));
|
||||
const segmentCount = barWidth > 0 ? Math.floor(barWidth / filledCharWidth) : 0;
|
||||
const filledSegments = segmentCount > 0 ? Math.round((barPercent / 100) * segmentCount) : 0;
|
||||
const filledStr = filledChar.repeat(filledSegments);
|
||||
const filledWidth = filledSegments * filledCharWidth;
|
||||
const remainingWidth = Math.max(0, barWidth - filledWidth);
|
||||
const emptySegments = emptyCharWidth > 0 ? Math.floor(remainingWidth / emptyCharWidth) : 0;
|
||||
const emptyStr = emptyChar.repeat(emptySegments);
|
||||
const emptyRendered = emptyChar === " " ? emptyStr : theme.fg(emptyColor, emptyStr);
|
||||
barStr = theme.fg(barColor as Parameters<typeof theme.fg>[0], filledStr) + emptyRendered;
|
||||
const barVisualWidth = visibleWidth(barStr);
|
||||
if (barVisualWidth < barWidth) {
|
||||
barStr += " ".repeat(barWidth - barVisualWidth);
|
||||
}
|
||||
} else {
|
||||
const emptyChar = barType === "braille" && brailleFillEmpty && barWidth > 1 ? "⣿" : " ";
|
||||
const { segments, minimal } = renderBarSegments(barPercent, barWidth, levels, {
|
||||
allowMinimum: true,
|
||||
emptyChar,
|
||||
});
|
||||
const filledColor = minimal ? "dim" : barColor;
|
||||
barStr = segments
|
||||
.map((segment) => {
|
||||
if (segment.filled) {
|
||||
return theme.fg(filledColor as Parameters<typeof theme.fg>[0], segment.char);
|
||||
}
|
||||
if (segment.char === " ") {
|
||||
return segment.char;
|
||||
}
|
||||
return theme.fg("dim", segment.char);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
if (settings?.display.containBar && barStr) {
|
||||
const leftCap = theme.fg(barColor as Parameters<typeof theme.fg>[0], "▕");
|
||||
const rightCap = theme.fg(barColor as Parameters<typeof theme.fg>[0], "▏");
|
||||
barStr = leftCap + barStr + rightCap;
|
||||
}
|
||||
}
|
||||
|
||||
let pctStr = "";
|
||||
if (barStyle === "percentage" || barStyle === "both") {
|
||||
// Special handling for Copilot Month window - can show percentage or requests
|
||||
if (window.label === "Month" && usage?.provider === "copilot") {
|
||||
const quotaDisplay = settings?.providers.copilot.quotaDisplay ?? "percentage";
|
||||
if (quotaDisplay === "requests" && usage.requestsRemaining !== undefined && usage.requestsEntitlement !== undefined) {
|
||||
const used = usage.requestsEntitlement - usage.requestsRemaining;
|
||||
const suffix = showUsageLabels ? " used" : "";
|
||||
pctStr = applyBaseTextColor(theme, usageLabelColor, `${used}/${usage.requestsEntitlement}${suffix}`);
|
||||
} else {
|
||||
const suffix = showUsageLabels ? " used" : "";
|
||||
pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`);
|
||||
}
|
||||
} else if (isCodex) {
|
||||
const suffix = showUsageLabels ? " rem." : "";
|
||||
pctStr = applyBaseTextColor(theme, usageLabelColor, `${displayPct}%${suffix}`);
|
||||
} else {
|
||||
const suffix = showUsageLabels ? " used" : "";
|
||||
pctStr = applyBaseTextColor(theme, usageLabelColor, `${usedPct}%${suffix}`);
|
||||
}
|
||||
}
|
||||
|
||||
const isActiveReset = window.resetDescription === "__ACTIVE__";
|
||||
const resetText = isActiveReset
|
||||
? undefined
|
||||
: resetTimeFormat === "datetime"
|
||||
? (window.resetAt ? formatResetDateTime(window.resetAt) : window.resetDescription)
|
||||
: window.resetDescription;
|
||||
const resetContainment = settings?.display.resetTimeContainment ?? "()";
|
||||
const leftSuffix = resetText && resetTimeFormat === "relative" && showUsageLabels ? " left" : "";
|
||||
|
||||
const displayLabel = getDisplayWindowLabel(window, model);
|
||||
const coloredTitle = applyBaseTextColor(theme, titleColor, displayLabel);
|
||||
const titlePart = showWindowTitle ? (boldWindowTitle ? theme.bold(coloredTitle) : coloredTitle) : "";
|
||||
|
||||
let labelPart = titlePart;
|
||||
if (resetText) {
|
||||
const resetBody = `${resetText}${leftSuffix}`;
|
||||
const { wrapped, attachWithSpace } = wrapResetContainment(resetBody, resetContainment);
|
||||
const coloredReset = applyBaseTextColor(theme, timerColor, wrapped);
|
||||
if (resetTimePosition === "front") {
|
||||
if (!titlePart) {
|
||||
labelPart = coloredReset;
|
||||
} else {
|
||||
labelPart = attachWithSpace ? `${titlePart} ${coloredReset}` : `${titlePart}${coloredReset}`;
|
||||
}
|
||||
} else if (resetTimePosition === "integrated") {
|
||||
labelPart = titlePart ? `${applyBaseTextColor(theme, timerColor, `${wrapped}/`)}${titlePart}` : coloredReset;
|
||||
} else if (resetTimePosition === "back") {
|
||||
labelPart = titlePart;
|
||||
}
|
||||
} else if (!titlePart) {
|
||||
labelPart = "";
|
||||
}
|
||||
|
||||
const resetPart =
|
||||
resetTimePosition === "back" && resetText
|
||||
? applyBaseTextColor(theme, timerColor, wrapResetContainment(`${resetText}${leftSuffix}`, resetContainment).wrapped)
|
||||
: "";
|
||||
|
||||
return {
|
||||
label: labelPart,
|
||||
bar: barStr,
|
||||
pct: pctStr,
|
||||
reset: resetPart,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format context window usage as a progress bar
|
||||
*/
|
||||
export function formatContextBar(
|
||||
theme: Theme,
|
||||
context: ContextInfo,
|
||||
settings?: Settings,
|
||||
options?: { barWidthOverride?: number }
|
||||
): string {
|
||||
// Create a pseudo-RateWindow for context display
|
||||
const contextWindow: RateWindow = {
|
||||
label: "Ctx",
|
||||
usedPercent: context.percent,
|
||||
// No reset description for context
|
||||
};
|
||||
// Format using the same window formatting logic, but with "used" semantics (not inverted)
|
||||
return formatUsageWindow(theme, contextWindow, false, settings, undefined, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a complete usage snapshot as a usage line
|
||||
*/
|
||||
export function formatUsageStatus(
|
||||
theme: Theme,
|
||||
usage: UsageSnapshot,
|
||||
model?: ModelInput,
|
||||
settings?: Settings,
|
||||
context?: ContextInfo
|
||||
): string | undefined {
|
||||
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
||||
const modelInfo = resolveModelInfo(model);
|
||||
const label = formatProviderLabel(theme, usage, settings, modelInfo);
|
||||
|
||||
// If no windows, just show the provider name with error
|
||||
if (usage.windows.length === 0) {
|
||||
const errorMsg = usage.error
|
||||
? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`)
|
||||
: "";
|
||||
if (!label) {
|
||||
return errorMsg;
|
||||
}
|
||||
return errorMsg ? `${label} ${errorMsg}` : label;
|
||||
}
|
||||
|
||||
// Build usage bars
|
||||
const parts: string[] = [];
|
||||
const isCodex = usage.provider === "codex";
|
||||
const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false);
|
||||
const modelId = modelInfo?.id;
|
||||
|
||||
// Add context bar as leftmost element if enabled
|
||||
const showContextBar = settings?.display.showContextBar ?? false;
|
||||
if (showContextBar && context && context.contextWindow > 0) {
|
||||
parts.push(formatContextBar(theme, context, settings));
|
||||
}
|
||||
|
||||
for (const w of usage.windows) {
|
||||
// Skip windows that are disabled in settings
|
||||
if (!shouldShowWindow(usage, w, settings, modelInfo)) {
|
||||
continue;
|
||||
}
|
||||
parts.push(formatUsageWindow(theme, w, invertUsage, settings, usage, undefined, modelInfo));
|
||||
}
|
||||
|
||||
// Add extra usage lines (extra usage off, copilot multiplier, etc.)
|
||||
const extras = getUsageExtras(usage, settings, modelId);
|
||||
for (const extra of extras) {
|
||||
parts.push(applyBaseTextColor(theme, baseTextColor, extra.label));
|
||||
}
|
||||
|
||||
// Build divider from settings
|
||||
const dividerChar = settings?.display.dividerCharacter ?? "•";
|
||||
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
|
||||
const blanksSetting = settings?.display.dividerBlanks ?? 1;
|
||||
const showProviderDivider = settings?.display.showProviderDivider ?? false;
|
||||
const blanksPerSide = typeof blanksSetting === "number" ? blanksSetting : 1;
|
||||
const spacing = " ".repeat(blanksPerSide);
|
||||
const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar;
|
||||
const divider = charToDisplay ? spacing + theme.fg(dividerColor, charToDisplay) + spacing : spacing + spacing;
|
||||
const labelGap = label && parts.length > 0
|
||||
? showProviderDivider && charToDisplay !== ""
|
||||
? divider
|
||||
: spacing
|
||||
: "";
|
||||
|
||||
return label + labelGap + parts.join(divider);
|
||||
}
|
||||
|
||||
export function formatUsageStatusWithWidth(
|
||||
theme: Theme,
|
||||
usage: UsageSnapshot,
|
||||
width: number,
|
||||
model?: ModelInput,
|
||||
settings?: Settings,
|
||||
options?: { labelGapFill?: boolean },
|
||||
context?: ContextInfo
|
||||
): string | undefined {
|
||||
const labelGapFill = options?.labelGapFill ?? false;
|
||||
const baseTextColor = resolveBaseTextColor(settings?.display.baseTextColor);
|
||||
const modelInfo = resolveModelInfo(model);
|
||||
const label = formatProviderLabel(theme, usage, settings, modelInfo);
|
||||
const showContextBar = settings?.display.showContextBar ?? false;
|
||||
const hasContext = showContextBar && context && context.contextWindow > 0;
|
||||
|
||||
// If no windows, just show the provider name with error
|
||||
if (usage.windows.length === 0) {
|
||||
const errorMsg = usage.error
|
||||
? applyBaseTextColor(theme, baseTextColor, `(${formatErrorForDisplay(usage.error)})`)
|
||||
: "";
|
||||
if (!label) {
|
||||
return errorMsg;
|
||||
}
|
||||
return errorMsg ? `${label} ${errorMsg}` : label;
|
||||
}
|
||||
|
||||
const barStyle: BarStyle = settings?.display.barStyle ?? "both";
|
||||
const hasBar = barStyle === "bar" || barStyle === "both";
|
||||
const barWidthSetting = settings?.display.barWidth ?? 6;
|
||||
const dividerBlanksSetting = settings?.display.dividerBlanks ?? 1;
|
||||
const dividerColor = resolveDividerColor(settings?.display.dividerColor);
|
||||
const showProviderDivider = settings?.display.showProviderDivider ?? false;
|
||||
const containBar = settings?.display.containBar ?? false;
|
||||
|
||||
const barFill = barWidthSetting === "fill";
|
||||
const barBaseWidth = typeof barWidthSetting === "number" ? barWidthSetting : (hasBar ? 1 : 0);
|
||||
const barContainerExtra = containBar && hasBar ? 2 : 0;
|
||||
const barBaseContentWidth = barFill ? 0 : barBaseWidth;
|
||||
const barBaseWidthCalc = barFill ? 0 : barBaseContentWidth + barContainerExtra;
|
||||
const barTotalBaseWidth = barBaseWidthCalc;
|
||||
const baseDividerBlanks = typeof dividerBlanksSetting === "number" ? dividerBlanksSetting : 1;
|
||||
|
||||
const dividerFill = dividerBlanksSetting === "fill";
|
||||
|
||||
// Build usage windows
|
||||
const windows: RateWindow[] = [];
|
||||
const isCodex = usage.provider === "codex";
|
||||
const invertUsage = isCodex && (settings?.providers.codex.invertUsage ?? false);
|
||||
const modelId = modelInfo?.id;
|
||||
|
||||
// Add context window as first entry if enabled
|
||||
let contextWindowIndex = -1;
|
||||
if (hasContext) {
|
||||
contextWindowIndex = windows.length;
|
||||
windows.push({
|
||||
label: "Ctx",
|
||||
usedPercent: context!.percent,
|
||||
});
|
||||
}
|
||||
|
||||
for (const w of usage.windows) {
|
||||
if (!shouldShowWindow(usage, w, settings, modelInfo)) {
|
||||
continue;
|
||||
}
|
||||
windows.push(w);
|
||||
}
|
||||
|
||||
const barEligibleCount = hasBar ? windows.length : 0;
|
||||
const extras = getUsageExtras(usage, settings, modelId);
|
||||
const extraParts = extras.map((extra) => applyBaseTextColor(theme, baseTextColor, extra.label));
|
||||
|
||||
const barSpacerWidth = hasBar ? 1 : 0;
|
||||
const baseWindowWidths = windows.map((w, i) => {
|
||||
// Context window uses false for invertUsage (always show used percentage)
|
||||
const isContext = i === contextWindowIndex;
|
||||
return (
|
||||
visibleWidth(
|
||||
formatUsageWindow(
|
||||
theme,
|
||||
w,
|
||||
isContext ? false : invertUsage,
|
||||
settings,
|
||||
isContext ? undefined : usage,
|
||||
{ barWidthOverride: 0 },
|
||||
modelInfo
|
||||
)
|
||||
) + barSpacerWidth
|
||||
);
|
||||
});
|
||||
const extraWidths = extraParts.map((part) => visibleWidth(part));
|
||||
|
||||
const partCount = windows.length + extraParts.length;
|
||||
const dividerCount = Math.max(0, partCount - 1);
|
||||
const dividerChar = settings?.display.dividerCharacter ?? "•";
|
||||
const charToDisplay = dividerChar === "blank" ? " " : dividerChar === "none" ? "" : dividerChar;
|
||||
const dividerBaseWidth = (charToDisplay ? 1 : 0) + baseDividerBlanks * 2;
|
||||
const labelGapEnabled = partCount > 0 && (label !== "" || labelGapFill);
|
||||
const providerDividerActive = showProviderDivider && charToDisplay !== "" && label !== "";
|
||||
const labelGapBaseWidth = labelGapEnabled
|
||||
? providerDividerActive
|
||||
? dividerBaseWidth
|
||||
: baseDividerBlanks
|
||||
: 0;
|
||||
|
||||
const labelWidth = visibleWidth(label);
|
||||
const baseTotalWidth =
|
||||
labelWidth +
|
||||
labelGapBaseWidth +
|
||||
baseWindowWidths.reduce((sum, w) => sum + w, 0) +
|
||||
extraWidths.reduce((sum, w) => sum + w, 0) +
|
||||
(barEligibleCount * barTotalBaseWidth) +
|
||||
(dividerCount * dividerBaseWidth);
|
||||
|
||||
let remainingWidth = width - baseTotalWidth;
|
||||
if (remainingWidth < 0) {
|
||||
remainingWidth = 0;
|
||||
}
|
||||
|
||||
const useBars = barFill && barEligibleCount > 0;
|
||||
const labelGapUnits = labelGapEnabled ? (providerDividerActive ? 2 : 1) : 0;
|
||||
const dividerSlots = dividerCount + (labelGapEnabled ? 1 : 0);
|
||||
const dividerUnits = dividerCount * 2 + labelGapUnits;
|
||||
const useDividers = dividerFill && dividerUnits > 0;
|
||||
|
||||
let barExtraTotal = 0;
|
||||
let dividerExtraTotal = 0;
|
||||
if (remainingWidth > 0 && (useBars || useDividers)) {
|
||||
const barWeight = useBars ? barEligibleCount : 0;
|
||||
const dividerWeight = useDividers ? dividerUnits : 0;
|
||||
const totalWeight = barWeight + dividerWeight;
|
||||
if (totalWeight > 0) {
|
||||
barExtraTotal = Math.floor((remainingWidth * barWeight) / totalWeight);
|
||||
dividerExtraTotal = remainingWidth - barExtraTotal;
|
||||
}
|
||||
}
|
||||
|
||||
const barWidths: number[] = windows.map(() => barBaseWidthCalc);
|
||||
if (useBars && barEligibleCount > 0) {
|
||||
const perBar = Math.floor(barExtraTotal / barEligibleCount);
|
||||
let remainder = barExtraTotal % barEligibleCount;
|
||||
for (let i = 0; i < barWidths.length; i++) {
|
||||
barWidths[i] = barBaseWidthCalc + perBar + (remainder > 0 ? 1 : 0);
|
||||
if (remainder > 0) remainder -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
let labelBlanks = labelGapEnabled ? baseDividerBlanks : 0;
|
||||
const dividerBlanks: number[] = [];
|
||||
if (dividerUnits > 0) {
|
||||
const baseUnit = useDividers ? Math.floor(dividerExtraTotal / dividerUnits) : 0;
|
||||
let remainderUnits = useDividers ? dividerExtraTotal % dividerUnits : 0;
|
||||
if (labelGapEnabled) {
|
||||
if (useDividers && providerDividerActive) {
|
||||
let extraUnits = baseUnit * 2;
|
||||
if (remainderUnits >= 2) {
|
||||
extraUnits += 2;
|
||||
remainderUnits -= 2;
|
||||
}
|
||||
labelBlanks = baseDividerBlanks + Math.floor(extraUnits / 2);
|
||||
} else if (useDividers) {
|
||||
labelBlanks = baseDividerBlanks + baseUnit + (remainderUnits > 0 ? 1 : 0);
|
||||
if (remainderUnits > 0) remainderUnits -= 1;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < dividerCount; i++) {
|
||||
let extraUnits = baseUnit * 2;
|
||||
if (remainderUnits >= 2) {
|
||||
extraUnits += 2;
|
||||
remainderUnits -= 2;
|
||||
}
|
||||
const blanks = baseDividerBlanks + Math.floor(extraUnits / 2);
|
||||
dividerBlanks.push(blanks);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < windows.length; i++) {
|
||||
const totalWidth = barWidths[i] ?? barBaseWidthCalc;
|
||||
const contentWidth = containBar ? Math.max(0, totalWidth - barContainerExtra) : totalWidth;
|
||||
const isContext = i === contextWindowIndex;
|
||||
parts.push(
|
||||
formatUsageWindow(
|
||||
theme,
|
||||
windows[i],
|
||||
isContext ? false : invertUsage,
|
||||
settings,
|
||||
isContext ? undefined : usage,
|
||||
{ barWidthOverride: contentWidth },
|
||||
modelInfo
|
||||
)
|
||||
);
|
||||
}
|
||||
for (const extra of extraParts) {
|
||||
parts.push(extra);
|
||||
}
|
||||
|
||||
let rest = "";
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
rest += parts[i];
|
||||
if (i < dividerCount) {
|
||||
const blanks = dividerBlanks[i] ?? baseDividerBlanks;
|
||||
const spacing = " ".repeat(Math.max(0, blanks));
|
||||
rest += charToDisplay
|
||||
? spacing + theme.fg(dividerColor, charToDisplay) + spacing
|
||||
: spacing + spacing;
|
||||
}
|
||||
}
|
||||
|
||||
let labelGapExtra = 0;
|
||||
if (labelGapFill && labelGapEnabled) {
|
||||
const restWidth = visibleWidth(rest);
|
||||
const labelGapWidth = providerDividerActive
|
||||
? (Math.max(0, labelBlanks) * 2) + (charToDisplay ? 1 : 0)
|
||||
: Math.max(0, labelBlanks);
|
||||
const totalWidth = visibleWidth(label) + restWidth + labelGapWidth;
|
||||
labelGapExtra = Math.max(0, width - totalWidth);
|
||||
}
|
||||
|
||||
let output = label;
|
||||
if (labelGapEnabled) {
|
||||
if (providerDividerActive) {
|
||||
const spacing = " ".repeat(Math.max(0, labelBlanks));
|
||||
output += spacing + theme.fg(dividerColor, charToDisplay) + spacing + " ".repeat(labelGapExtra);
|
||||
} else {
|
||||
output += " ".repeat(Math.max(0, labelBlanks + labelGapExtra));
|
||||
}
|
||||
}
|
||||
output += rest;
|
||||
|
||||
if (width > 0 && visibleWidth(output) > width) {
|
||||
return truncateToWidth(output, width, "");
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Shared path helpers for sub-bar settings storage.
|
||||
*/
|
||||
|
||||
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SETTINGS_FILE_NAME = "pi-sub-bar-settings.json";
|
||||
|
||||
export function getExtensionDir(): string {
|
||||
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
}
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
return join(getAgentDir(), SETTINGS_FILE_NAME);
|
||||
}
|
||||
|
||||
export function getLegacySettingsPath(): string {
|
||||
return join(getExtensionDir(), "settings.json");
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Provider-specific extra usage lines (non-window info).
|
||||
*/
|
||||
|
||||
import type { UsageSnapshot } from "../types.js";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import { PROVIDER_METADATA, type UsageExtra } from "./metadata.js";
|
||||
|
||||
export type { UsageExtra } from "./metadata.js";
|
||||
|
||||
export function getUsageExtras(
|
||||
usage: UsageSnapshot,
|
||||
settings?: Settings,
|
||||
modelId?: string
|
||||
): UsageExtra[] {
|
||||
const handler = PROVIDER_METADATA[usage.provider]?.getExtras;
|
||||
if (handler) {
|
||||
return handler(usage, settings, modelId);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Provider metadata shared across the extension.
|
||||
*/
|
||||
|
||||
import type { RateWindow, UsageSnapshot, ProviderName, ModelInfo } from "../types.js";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import { getModelMultiplier, normalizeTokens } from "../utils.js";
|
||||
import { PROVIDER_METADATA as BASE_METADATA, type ProviderMetadata as BaseProviderMetadata } from "../../shared.js";
|
||||
|
||||
export { PROVIDERS, PROVIDER_DISPLAY_NAMES } from "../../shared.js";
|
||||
export type { ProviderStatusConfig, ProviderDetectionConfig } from "../../shared.js";
|
||||
|
||||
export interface UsageExtra {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ProviderMetadata extends BaseProviderMetadata {
|
||||
isWindowVisible?: (usage: UsageSnapshot, window: RateWindow, settings?: Settings, model?: ModelInfo) => boolean;
|
||||
getExtras?: (usage: UsageSnapshot, settings?: Settings, modelId?: string) => UsageExtra[];
|
||||
}
|
||||
|
||||
const anthropicWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
|
||||
if (!settings) return true;
|
||||
const ps = settings.providers.anthropic;
|
||||
if (window.label === "5h") return ps.windows.show5h;
|
||||
if (window.label === "Week") return ps.windows.show7d;
|
||||
if (window.label.startsWith("Extra [")) return ps.windows.showExtra;
|
||||
return true;
|
||||
};
|
||||
|
||||
const copilotWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
|
||||
if (!settings) return true;
|
||||
const ps = settings.providers.copilot;
|
||||
if (window.label === "Month") return ps.windows.showMonth;
|
||||
return true;
|
||||
};
|
||||
|
||||
const geminiWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
|
||||
if (!settings) return true;
|
||||
const ps = settings.providers.gemini;
|
||||
if (window.label === "Pro") return ps.windows.showPro;
|
||||
if (window.label === "Flash") return ps.windows.showFlash;
|
||||
return true;
|
||||
};
|
||||
|
||||
const antigravityWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, model) => {
|
||||
if (!settings) return true;
|
||||
const ps = settings.providers.antigravity;
|
||||
const label = window.label.trim();
|
||||
const normalized = label.toLowerCase().replace(/\s+/g, "_");
|
||||
if (normalized === "tab_flash_lite_preview") return false;
|
||||
|
||||
const labelTokens = normalizeTokens(label);
|
||||
|
||||
const modelProvider = model?.provider?.toLowerCase() ?? "";
|
||||
const modelId = model?.id;
|
||||
const providerMatches = modelProvider.includes("antigravity");
|
||||
if (ps.showCurrentModel && providerMatches && modelId) {
|
||||
const modelTokens = normalizeTokens(modelId);
|
||||
const match = modelTokens.length > 0 && modelTokens.every((token) => labelTokens.includes(token));
|
||||
if (match) return true;
|
||||
}
|
||||
|
||||
if (ps.showScopedModels) {
|
||||
const scopedPatterns = model?.scopedModelPatterns ?? [];
|
||||
const matchesScoped = scopedPatterns.some((pattern) => {
|
||||
if (!pattern) return false;
|
||||
const [rawPattern] = pattern.split(":");
|
||||
const trimmed = rawPattern?.trim();
|
||||
if (!trimmed) return false;
|
||||
const hasProvider = trimmed.includes("/");
|
||||
if (!hasProvider) return false;
|
||||
const providerPart = trimmed.slice(0, trimmed.indexOf("/")).trim().toLowerCase();
|
||||
if (!providerPart.includes("antigravity")) return false;
|
||||
const base = trimmed.slice(trimmed.lastIndexOf("/") + 1);
|
||||
const tokens = normalizeTokens(base);
|
||||
return tokens.length > 0 && tokens.every((token) => labelTokens.includes(token));
|
||||
});
|
||||
if (matchesScoped) return true;
|
||||
}
|
||||
|
||||
const visibility = ps.modelVisibility?.[label];
|
||||
return visibility === true;
|
||||
};
|
||||
|
||||
const codexWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, model) => {
|
||||
if (!settings) return true;
|
||||
const ps = settings.providers.codex;
|
||||
const isSparkModel = isCodexSparkModel(model);
|
||||
const isSparkWindow = isCodexSparkWindow(window);
|
||||
if (isSparkWindow) {
|
||||
if (!isSparkModel) return false;
|
||||
return shouldShowCodexWindowBySetting(ps, window);
|
||||
}
|
||||
if (isSparkModel) {
|
||||
return false;
|
||||
}
|
||||
return shouldShowCodexWindowBySetting(ps, window);
|
||||
};
|
||||
|
||||
const isCodexSparkModel = (model?: ModelInfo): boolean => {
|
||||
const tokens = normalizeTokens(model?.id ?? "");
|
||||
return tokens.includes("codex") && tokens.includes("spark");
|
||||
};
|
||||
|
||||
const isCodexSparkWindow = (window: RateWindow): boolean => {
|
||||
const tokens = normalizeTokens(window.label ?? "");
|
||||
return tokens.includes("codex") && tokens.includes("spark");
|
||||
};
|
||||
|
||||
const shouldShowCodexWindowBySetting = (
|
||||
ps: Settings["providers"]["codex"],
|
||||
window: RateWindow
|
||||
): boolean => {
|
||||
if (window.label === "") return true;
|
||||
if (/\b\d+h$/.test(window.label.trim())) {
|
||||
return ps.windows.showPrimary;
|
||||
}
|
||||
if (window.label === "Day" || window.label === "Week" || /\b(day|week)\b/.test(window.label.toLowerCase())) {
|
||||
return ps.windows.showSecondary;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const kiroWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
|
||||
if (!settings) return true;
|
||||
const ps = settings.providers.kiro;
|
||||
if (window.label === "Credits") return ps.windows.showCredits;
|
||||
return true;
|
||||
};
|
||||
|
||||
const zaiWindowVisible: ProviderMetadata["isWindowVisible"] = (_usage, window, settings, _model) => {
|
||||
if (!settings) return true;
|
||||
const ps = settings.providers.zai;
|
||||
if (window.label === "Tokens") return ps.windows.showTokens;
|
||||
if (window.label === "Monthly") return ps.windows.showMonthly;
|
||||
return true;
|
||||
};
|
||||
|
||||
const anthropicExtras: ProviderMetadata["getExtras"] = (usage, settings) => {
|
||||
const extras: UsageExtra[] = [];
|
||||
const showExtraWindow = settings?.providers.anthropic.windows.showExtra ?? true;
|
||||
if (showExtraWindow && usage.extraUsageEnabled === false) {
|
||||
extras.push({ label: "Extra [off]" });
|
||||
}
|
||||
return extras;
|
||||
};
|
||||
|
||||
const copilotExtras: ProviderMetadata["getExtras"] = (usage, settings, modelId) => {
|
||||
const extras: UsageExtra[] = [];
|
||||
const showMultiplier = settings?.providers.copilot.showMultiplier ?? true;
|
||||
const showRequestsLeft = settings?.providers.copilot.showRequestsLeft ?? true;
|
||||
if (!showMultiplier) return extras;
|
||||
|
||||
const multiplier = getModelMultiplier(modelId);
|
||||
const remaining = usage.requestsRemaining;
|
||||
if (multiplier !== undefined) {
|
||||
let multiplierStr = `Model multiplier: ${multiplier}x`;
|
||||
if (showRequestsLeft && remaining !== undefined) {
|
||||
const leftCount = Math.floor(remaining / Math.max(multiplier, 0.0001));
|
||||
multiplierStr += ` (${leftCount} req. left)`;
|
||||
}
|
||||
extras.push({ label: multiplierStr });
|
||||
}
|
||||
return extras;
|
||||
};
|
||||
|
||||
export const PROVIDER_METADATA: Record<ProviderName, ProviderMetadata> = {
|
||||
anthropic: {
|
||||
...BASE_METADATA.anthropic,
|
||||
isWindowVisible: anthropicWindowVisible,
|
||||
getExtras: anthropicExtras,
|
||||
},
|
||||
copilot: {
|
||||
...BASE_METADATA.copilot,
|
||||
isWindowVisible: copilotWindowVisible,
|
||||
getExtras: copilotExtras,
|
||||
},
|
||||
gemini: {
|
||||
...BASE_METADATA.gemini,
|
||||
isWindowVisible: geminiWindowVisible,
|
||||
},
|
||||
antigravity: {
|
||||
...BASE_METADATA.antigravity,
|
||||
isWindowVisible: antigravityWindowVisible,
|
||||
},
|
||||
codex: {
|
||||
...BASE_METADATA.codex,
|
||||
isWindowVisible: codexWindowVisible,
|
||||
},
|
||||
kiro: {
|
||||
...BASE_METADATA.kiro,
|
||||
isWindowVisible: kiroWindowVisible,
|
||||
},
|
||||
zai: {
|
||||
...BASE_METADATA.zai,
|
||||
isWindowVisible: zaiWindowVisible,
|
||||
},
|
||||
"opencode-go": {
|
||||
...BASE_METADATA["opencode-go"],
|
||||
},
|
||||
};
|
||||
@@ -1,359 +0,0 @@
|
||||
/**
|
||||
* Provider-specific settings helpers.
|
||||
*/
|
||||
|
||||
import type { SettingItem } from "@mariozechner/pi-tui";
|
||||
import type { ProviderName } from "../types.js";
|
||||
import type {
|
||||
Settings,
|
||||
BaseProviderSettings,
|
||||
AnthropicProviderSettings,
|
||||
CopilotProviderSettings,
|
||||
GeminiProviderSettings,
|
||||
AntigravityProviderSettings,
|
||||
CodexProviderSettings,
|
||||
KiroProviderSettings,
|
||||
ZaiProviderSettings,
|
||||
} from "../settings-types.js";
|
||||
|
||||
function buildBaseProviderItems(ps: BaseProviderSettings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "showStatus",
|
||||
label: "Show Status Indicator",
|
||||
currentValue: ps.showStatus ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show status indicator for this provider.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function applyBaseProviderSetting(ps: BaseProviderSettings, id: string, value: string): boolean {
|
||||
switch (id) {
|
||||
case "showStatus":
|
||||
ps.showStatus = value === "on";
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build settings items for a specific provider.
|
||||
*/
|
||||
export function buildProviderSettingsItems(settings: Settings, provider: ProviderName): SettingItem[] {
|
||||
const ps = settings.providers[provider];
|
||||
const items: SettingItem[] = [...buildBaseProviderItems(ps)];
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const anthroSettings = ps as AnthropicProviderSettings;
|
||||
items.push(
|
||||
{
|
||||
id: "show5h",
|
||||
label: "Show 5h Window",
|
||||
currentValue: anthroSettings.windows.show5h ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the 5-hour usage window.",
|
||||
},
|
||||
{
|
||||
id: "show7d",
|
||||
label: "Show Week Window",
|
||||
currentValue: anthroSettings.windows.show7d ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the weekly usage window.",
|
||||
},
|
||||
{
|
||||
id: "showExtra",
|
||||
label: "Show Extra Window",
|
||||
currentValue: anthroSettings.windows.showExtra ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the extra usage window.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "copilot") {
|
||||
const copilotSettings = ps as CopilotProviderSettings;
|
||||
items.push(
|
||||
{
|
||||
id: "showMultiplier",
|
||||
label: "Show Model Multiplier",
|
||||
currentValue: copilotSettings.showMultiplier ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show request cost multiplier for the current model.",
|
||||
},
|
||||
{
|
||||
id: "showRequestsLeft",
|
||||
label: "Show Requests Remaining",
|
||||
currentValue: copilotSettings.showRequestsLeft ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Estimate requests remaining based on the multiplier.",
|
||||
},
|
||||
{
|
||||
id: "quotaDisplay",
|
||||
label: "Show Quota in",
|
||||
currentValue: copilotSettings.quotaDisplay,
|
||||
values: ["percentage", "requests"],
|
||||
description: "Display Copilot usage as percentage or requests.",
|
||||
},
|
||||
{
|
||||
id: "showMonth",
|
||||
label: "Show Month Window",
|
||||
currentValue: copilotSettings.windows.showMonth ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the monthly usage window.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "gemini") {
|
||||
const geminiSettings = ps as GeminiProviderSettings;
|
||||
items.push(
|
||||
{
|
||||
id: "showPro",
|
||||
label: "Show Pro Window",
|
||||
currentValue: geminiSettings.windows.showPro ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the Pro quota window.",
|
||||
},
|
||||
{
|
||||
id: "showFlash",
|
||||
label: "Show Flash Window",
|
||||
currentValue: geminiSettings.windows.showFlash ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the Flash quota window.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "antigravity") {
|
||||
const antigravitySettings = ps as AntigravityProviderSettings;
|
||||
items.push(
|
||||
{
|
||||
id: "showCurrentModel",
|
||||
label: "Always Show Current Model",
|
||||
currentValue: antigravitySettings.showCurrentModel ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the active Antigravity model even if hidden.",
|
||||
},
|
||||
{
|
||||
id: "showScopedModels",
|
||||
label: "Show Scoped Models",
|
||||
currentValue: antigravitySettings.showScopedModels ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show Antigravity models that are in the scoped model rotation.",
|
||||
},
|
||||
);
|
||||
|
||||
const modelVisibility = antigravitySettings.modelVisibility ?? {};
|
||||
const modelOrder = antigravitySettings.modelOrder?.length
|
||||
? antigravitySettings.modelOrder
|
||||
: Object.keys(modelVisibility).sort((a, b) => a.localeCompare(b));
|
||||
const seenModels = new Set<string>();
|
||||
|
||||
for (const model of modelOrder) {
|
||||
if (!model || seenModels.has(model)) continue;
|
||||
seenModels.add(model);
|
||||
const normalized = model.toLowerCase().replace(/\s+/g, "_");
|
||||
if (normalized === "tab_flash_lite_preview") continue;
|
||||
const visible = modelVisibility[model] !== false;
|
||||
items.push({
|
||||
id: `model:${model}`,
|
||||
label: model,
|
||||
currentValue: visible ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Toggle this model window.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "codex") {
|
||||
const codexSettings = ps as CodexProviderSettings;
|
||||
items.push(
|
||||
{
|
||||
id: "invertUsage",
|
||||
label: "Invert Usage",
|
||||
currentValue: codexSettings.invertUsage ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show remaining-style usage for Codex.",
|
||||
},
|
||||
{
|
||||
id: "showPrimary",
|
||||
label: "Show Primary Window",
|
||||
currentValue: codexSettings.windows.showPrimary ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the primary usage window.",
|
||||
},
|
||||
{
|
||||
id: "showSecondary",
|
||||
label: "Show Secondary Window",
|
||||
currentValue: codexSettings.windows.showSecondary ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show secondary windows (day/week).",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "kiro") {
|
||||
const kiroSettings = ps as KiroProviderSettings;
|
||||
items.push({
|
||||
id: "showCredits",
|
||||
label: "Show Credits Window",
|
||||
currentValue: kiroSettings.windows.showCredits ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the credits usage window.",
|
||||
});
|
||||
}
|
||||
|
||||
if (provider === "zai") {
|
||||
const zaiSettings = ps as ZaiProviderSettings;
|
||||
items.push(
|
||||
{
|
||||
id: "showTokens",
|
||||
label: "Show Tokens Window",
|
||||
currentValue: zaiSettings.windows.showTokens ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the tokens usage window.",
|
||||
},
|
||||
{
|
||||
id: "showMonthly",
|
||||
label: "Show Monthly Window",
|
||||
currentValue: zaiSettings.windows.showMonthly ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the monthly usage window.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a provider settings change in-place.
|
||||
*/
|
||||
export function applyProviderSettingsChange(
|
||||
settings: Settings,
|
||||
provider: ProviderName,
|
||||
id: string,
|
||||
value: string
|
||||
): Settings {
|
||||
const ps = settings.providers[provider];
|
||||
if (applyBaseProviderSetting(ps, id, value)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const anthroSettings = ps as AnthropicProviderSettings;
|
||||
switch (id) {
|
||||
case "show5h":
|
||||
anthroSettings.windows.show5h = value === "on";
|
||||
break;
|
||||
case "show7d":
|
||||
anthroSettings.windows.show7d = value === "on";
|
||||
break;
|
||||
case "showExtra":
|
||||
anthroSettings.windows.showExtra = value === "on";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "copilot") {
|
||||
const copilotSettings = ps as CopilotProviderSettings;
|
||||
switch (id) {
|
||||
case "showMultiplier":
|
||||
copilotSettings.showMultiplier = value === "on";
|
||||
break;
|
||||
case "showRequestsLeft":
|
||||
copilotSettings.showRequestsLeft = value === "on";
|
||||
break;
|
||||
case "quotaDisplay":
|
||||
copilotSettings.quotaDisplay = value as "percentage" | "requests";
|
||||
break;
|
||||
case "showMonth":
|
||||
copilotSettings.windows.showMonth = value === "on";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "gemini") {
|
||||
const geminiSettings = ps as GeminiProviderSettings;
|
||||
switch (id) {
|
||||
case "showPro":
|
||||
geminiSettings.windows.showPro = value === "on";
|
||||
break;
|
||||
case "showFlash":
|
||||
geminiSettings.windows.showFlash = value === "on";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "antigravity") {
|
||||
const antigravitySettings = ps as AntigravityProviderSettings;
|
||||
switch (id) {
|
||||
case "showModels":
|
||||
antigravitySettings.windows.showModels = value === "on";
|
||||
break;
|
||||
case "showCurrentModel":
|
||||
antigravitySettings.showCurrentModel = value === "on";
|
||||
break;
|
||||
case "showScopedModels":
|
||||
antigravitySettings.showScopedModels = value === "on";
|
||||
break;
|
||||
default:
|
||||
if (id.startsWith("model:")) {
|
||||
const model = id.slice("model:".length);
|
||||
if (model) {
|
||||
if (!antigravitySettings.modelVisibility) {
|
||||
antigravitySettings.modelVisibility = {};
|
||||
}
|
||||
antigravitySettings.modelVisibility[model] = value === "on";
|
||||
if (!antigravitySettings.modelOrder) {
|
||||
antigravitySettings.modelOrder = [];
|
||||
}
|
||||
if (!antigravitySettings.modelOrder.includes(model)) {
|
||||
antigravitySettings.modelOrder.push(model);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "codex") {
|
||||
const codexSettings = ps as CodexProviderSettings;
|
||||
switch (id) {
|
||||
case "invertUsage":
|
||||
codexSettings.invertUsage = value === "on";
|
||||
break;
|
||||
case "showPrimary":
|
||||
codexSettings.windows.showPrimary = value === "on";
|
||||
break;
|
||||
case "showSecondary":
|
||||
codexSettings.windows.showSecondary = value === "on";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "kiro") {
|
||||
const kiroSettings = ps as KiroProviderSettings;
|
||||
switch (id) {
|
||||
case "showCredits":
|
||||
kiroSettings.windows.showCredits = value === "on";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === "zai") {
|
||||
const zaiSettings = ps as ZaiProviderSettings;
|
||||
switch (id) {
|
||||
case "showTokens":
|
||||
zaiSettings.windows.showTokens = value === "on";
|
||||
break;
|
||||
case "showMonthly":
|
||||
zaiSettings.windows.showMonthly = value === "on";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Provider-specific window visibility rules.
|
||||
*/
|
||||
|
||||
import type { RateWindow, UsageSnapshot, ModelInfo } from "../types.js";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import { PROVIDER_METADATA } from "./metadata.js";
|
||||
|
||||
/**
|
||||
* Check if a window should be shown based on settings.
|
||||
*/
|
||||
export function shouldShowWindow(
|
||||
usage: UsageSnapshot,
|
||||
window: RateWindow,
|
||||
settings?: Settings,
|
||||
model?: ModelInfo
|
||||
): boolean {
|
||||
const handler = PROVIDER_METADATA[usage.provider]?.isWindowVisible;
|
||||
if (handler) {
|
||||
return handler(usage, window, settings, model);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,611 +0,0 @@
|
||||
/**
|
||||
* Settings types and defaults for sub-bar
|
||||
*/
|
||||
|
||||
import type { CoreSettings, ProviderName } from "./shared.js";
|
||||
import { PROVIDERS } from "./shared.js";
|
||||
import type { ThemeColor } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
/**
|
||||
* Bar display style
|
||||
*/
|
||||
export type BarStyle = "bar" | "percentage" | "both";
|
||||
|
||||
/**
|
||||
* Bar rendering type
|
||||
*/
|
||||
export type BarType = "horizontal-bar" | "horizontal-single" | "vertical" | "braille" | "shade";
|
||||
|
||||
/**
|
||||
* Color scheme for usage bars
|
||||
*/
|
||||
export type ColorScheme = "monochrome" | "base-warning-error" | "success-base-warning-error";
|
||||
|
||||
/**
|
||||
* Progress bar character style
|
||||
*/
|
||||
export type BarCharacter = "light" | "heavy" | "double" | "block" | (string & {});
|
||||
|
||||
/**
|
||||
* Divider character style
|
||||
*/
|
||||
export type DividerCharacter =
|
||||
| "none"
|
||||
| "blank"
|
||||
| "|"
|
||||
| "│"
|
||||
| "┃"
|
||||
| "┆"
|
||||
| "┇"
|
||||
| "║"
|
||||
| "•"
|
||||
| "●"
|
||||
| "○"
|
||||
| "◇"
|
||||
| (string & {});
|
||||
|
||||
/**
|
||||
* Widget overflow mode
|
||||
*/
|
||||
export type OverflowMode = "truncate" | "wrap";
|
||||
export type WidgetWrapping = OverflowMode;
|
||||
|
||||
/**
|
||||
* Widget placement
|
||||
*/
|
||||
export type WidgetPlacement = "belowEditor";
|
||||
|
||||
/**
|
||||
* Alignment for the widget
|
||||
*/
|
||||
export type DisplayAlignment = "left" | "center" | "right" | "split";
|
||||
|
||||
/**
|
||||
* Provider label prefix
|
||||
*/
|
||||
export type ProviderLabel = "plan" | "subscription" | "sub" | "none" | (string & {});
|
||||
|
||||
/**
|
||||
* Reset timer format
|
||||
*/
|
||||
export type ResetTimeFormat = "relative" | "datetime";
|
||||
|
||||
/**
|
||||
* Reset timer containment style
|
||||
*/
|
||||
export type ResetTimerContainment = "none" | "blank" | "()" | "[]" | "<>" | (string & {});
|
||||
|
||||
/**
|
||||
* Status indicator display mode
|
||||
*/
|
||||
export type StatusIndicatorMode = "icon" | "text" | "icon+text";
|
||||
|
||||
/**
|
||||
* Status icon pack selection
|
||||
*/
|
||||
export type StatusIconPack = "minimal" | "emoji" | "custom";
|
||||
|
||||
export interface UsageColorTargets {
|
||||
title: boolean;
|
||||
timer: boolean;
|
||||
bar: boolean;
|
||||
usageLabel: boolean;
|
||||
status: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Divider color options (subset of theme colors).
|
||||
*/
|
||||
export const DIVIDER_COLOR_OPTIONS = [
|
||||
"primary",
|
||||
"text",
|
||||
"muted",
|
||||
"dim",
|
||||
"success",
|
||||
"warning",
|
||||
"error",
|
||||
"border",
|
||||
"borderMuted",
|
||||
"borderAccent",
|
||||
] as const;
|
||||
|
||||
export type DividerColor = (typeof DIVIDER_COLOR_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Background color options (theme background colors).
|
||||
*/
|
||||
export const BACKGROUND_COLOR_OPTIONS = [
|
||||
"selectedBg",
|
||||
"userMessageBg",
|
||||
"customMessageBg",
|
||||
"toolPendingBg",
|
||||
"toolSuccessBg",
|
||||
"toolErrorBg",
|
||||
] as const;
|
||||
|
||||
export type BackgroundColor = (typeof BACKGROUND_COLOR_OPTIONS)[number];
|
||||
|
||||
/**
|
||||
* Base text/background color options.
|
||||
*/
|
||||
export const BASE_COLOR_OPTIONS = [...DIVIDER_COLOR_OPTIONS, ...BACKGROUND_COLOR_OPTIONS] as const;
|
||||
|
||||
/**
|
||||
* Base text color for widget labels
|
||||
*/
|
||||
export type BaseTextColor = (typeof BASE_COLOR_OPTIONS)[number];
|
||||
|
||||
export function normalizeDividerColor(value?: string): DividerColor {
|
||||
if (!value) return "borderMuted";
|
||||
if (value === "accent" || value === "primary") return "primary";
|
||||
if ((DIVIDER_COLOR_OPTIONS as readonly string[]).includes(value)) {
|
||||
return value as DividerColor;
|
||||
}
|
||||
return "borderMuted";
|
||||
}
|
||||
|
||||
export function resolveDividerColor(value?: string): ThemeColor {
|
||||
const normalized = normalizeDividerColor(value);
|
||||
switch (normalized) {
|
||||
case "primary":
|
||||
return "accent";
|
||||
case "border":
|
||||
case "borderMuted":
|
||||
case "borderAccent":
|
||||
case "success":
|
||||
case "warning":
|
||||
case "error":
|
||||
case "muted":
|
||||
case "dim":
|
||||
case "text":
|
||||
return normalized as ThemeColor;
|
||||
default:
|
||||
return "borderMuted";
|
||||
}
|
||||
}
|
||||
|
||||
export function isBackgroundColor(value?: BaseTextColor): value is BackgroundColor {
|
||||
return !!value && (BACKGROUND_COLOR_OPTIONS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function normalizeBaseTextColor(value?: string): BaseTextColor {
|
||||
if (!value) return "dim";
|
||||
if (value === "accent" || value === "primary") return "primary";
|
||||
if ((BASE_COLOR_OPTIONS as readonly string[]).includes(value)) {
|
||||
return value as BaseTextColor;
|
||||
}
|
||||
return "dim";
|
||||
}
|
||||
|
||||
export function resolveBaseTextColor(value?: string): BaseTextColor {
|
||||
return normalizeBaseTextColor(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bar width configuration
|
||||
*/
|
||||
export type BarWidth = number | "fill";
|
||||
|
||||
/**
|
||||
* Divider blank spacing configuration
|
||||
*/
|
||||
export type DividerBlanks = number | "fill";
|
||||
|
||||
/**
|
||||
* Provider settings (UI-only)
|
||||
*/
|
||||
export interface BaseProviderSettings {
|
||||
/** Show status indicator */
|
||||
showStatus: boolean;
|
||||
}
|
||||
|
||||
export interface AnthropicProviderSettings extends BaseProviderSettings {
|
||||
windows: {
|
||||
show5h: boolean;
|
||||
show7d: boolean;
|
||||
showExtra: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CopilotProviderSettings extends BaseProviderSettings {
|
||||
showMultiplier: boolean;
|
||||
showRequestsLeft: boolean;
|
||||
quotaDisplay: "percentage" | "requests";
|
||||
windows: {
|
||||
showMonth: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeminiProviderSettings extends BaseProviderSettings {
|
||||
windows: {
|
||||
showPro: boolean;
|
||||
showFlash: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AntigravityProviderSettings extends BaseProviderSettings {
|
||||
showCurrentModel: boolean;
|
||||
showScopedModels: boolean;
|
||||
windows: {
|
||||
showModels: boolean;
|
||||
};
|
||||
modelVisibility: Record<string, boolean>;
|
||||
modelOrder: string[];
|
||||
}
|
||||
|
||||
export interface CodexProviderSettings extends BaseProviderSettings {
|
||||
invertUsage: boolean;
|
||||
windows: {
|
||||
showPrimary: boolean;
|
||||
showSecondary: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface KiroProviderSettings extends BaseProviderSettings {
|
||||
windows: {
|
||||
showCredits: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ZaiProviderSettings extends BaseProviderSettings {
|
||||
windows: {
|
||||
showTokens: boolean;
|
||||
showMonthly: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProviderSettingsMap {
|
||||
anthropic: AnthropicProviderSettings;
|
||||
copilot: CopilotProviderSettings;
|
||||
gemini: GeminiProviderSettings;
|
||||
antigravity: AntigravityProviderSettings;
|
||||
codex: CodexProviderSettings;
|
||||
kiro: KiroProviderSettings;
|
||||
zai: ZaiProviderSettings;
|
||||
"opencode-go": BaseProviderSettings;
|
||||
}
|
||||
|
||||
export type { BehaviorSettings, CoreSettings } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Keybinding settings.
|
||||
* Values are key-combo strings accepted by pi's registerShortcut (e.g. "ctrl+alt+p").
|
||||
* Use "none" to disable a shortcut.
|
||||
* Changes take effect after pi restart.
|
||||
*/
|
||||
export interface KeybindingSettings {
|
||||
/** Shortcut to cycle through providers */
|
||||
cycleProvider: string;
|
||||
/** Shortcut to toggle reset timer format */
|
||||
toggleResetFormat: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display settings
|
||||
*/
|
||||
export interface DisplaySettings {
|
||||
/** Alignment */
|
||||
alignment: DisplayAlignment;
|
||||
/** Bar display style */
|
||||
barStyle: BarStyle;
|
||||
/** Bar type */
|
||||
barType: BarType;
|
||||
/** Width of the progress bar in characters */
|
||||
barWidth: BarWidth;
|
||||
/** Progress bar character */
|
||||
barCharacter: BarCharacter;
|
||||
/** Contain bar within ▕ and ▏ */
|
||||
containBar: boolean;
|
||||
/** Fill empty braille segments with dim full blocks */
|
||||
brailleFillEmpty: boolean;
|
||||
/** Use full braille blocks for filled segments */
|
||||
brailleFullBlocks: boolean;
|
||||
/** Color scheme for bars */
|
||||
colorScheme: ColorScheme;
|
||||
/** Elements colored by the usage scheme */
|
||||
usageColorTargets: UsageColorTargets;
|
||||
/** Reset time display position */
|
||||
resetTimePosition: "off" | "front" | "back" | "integrated";
|
||||
/** Reset time format */
|
||||
resetTimeFormat: ResetTimeFormat;
|
||||
/** Reset timer containment */
|
||||
resetTimeContainment: ResetTimerContainment;
|
||||
/** Status indicator mode */
|
||||
statusIndicatorMode: StatusIndicatorMode;
|
||||
/** Status icon pack */
|
||||
statusIconPack: StatusIconPack;
|
||||
/** Custom status icon pack (four characters) */
|
||||
statusIconCustom: string;
|
||||
/** Show divider between status and provider */
|
||||
statusProviderDivider: boolean;
|
||||
/** Dismiss status when operational */
|
||||
statusDismissOk: boolean;
|
||||
/** Show provider display name */
|
||||
showProviderName: boolean;
|
||||
/** Provider label prefix */
|
||||
providerLabel: ProviderLabel;
|
||||
/** Show colon after provider label */
|
||||
providerLabelColon: boolean;
|
||||
/** Bold provider name and colon */
|
||||
providerLabelBold: boolean;
|
||||
/** Base text color for widget labels */
|
||||
baseTextColor: BaseTextColor;
|
||||
/** Background color for the widget line */
|
||||
backgroundColor: BaseTextColor;
|
||||
/** Show window titles (5h, Week, etc.) */
|
||||
showWindowTitle: boolean;
|
||||
/** Bold window titles (5h, Week, etc.) */
|
||||
boldWindowTitle: boolean;
|
||||
/** Show usage labels (used/rem.) */
|
||||
showUsageLabels: boolean;
|
||||
/** Divider character */
|
||||
dividerCharacter: DividerCharacter;
|
||||
/** Divider color */
|
||||
dividerColor: DividerColor;
|
||||
/** Blanks before and after divider */
|
||||
dividerBlanks: DividerBlanks;
|
||||
/** Show divider between provider label and usage */
|
||||
showProviderDivider: boolean;
|
||||
/** Connect divider glyphs to the bottom divider line */
|
||||
dividerFooterJoin: boolean;
|
||||
/** Show divider line above the bar */
|
||||
showTopDivider: boolean;
|
||||
/** Show divider line below the bar */
|
||||
showBottomDivider: boolean;
|
||||
/** Widget overflow mode */
|
||||
overflow: OverflowMode;
|
||||
/** Left padding inside widget */
|
||||
paddingLeft: number;
|
||||
/** Right padding inside widget */
|
||||
paddingRight: number;
|
||||
/** Widget placement */
|
||||
widgetPlacement: WidgetPlacement;
|
||||
/** Show context window usage as leftmost progress bar */
|
||||
showContextBar: boolean;
|
||||
/** Error threshold (percentage remaining below this = red) */
|
||||
errorThreshold: number;
|
||||
/** Warning threshold (percentage remaining below this = yellow) */
|
||||
warningThreshold: number;
|
||||
/** Success threshold (percentage remaining above this = green, gradient only) */
|
||||
successThreshold: number;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* All settings
|
||||
*/
|
||||
export interface DisplayTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
display: DisplaySettings;
|
||||
source?: "saved" | "imported";
|
||||
}
|
||||
|
||||
export interface Settings extends Omit<CoreSettings, "providers"> {
|
||||
/** Version for migration */
|
||||
version: number;
|
||||
/** Provider-specific UI settings */
|
||||
providers: ProviderSettingsMap;
|
||||
/** Display settings */
|
||||
display: DisplaySettings;
|
||||
/** Stored display themes */
|
||||
displayThemes: DisplayTheme[];
|
||||
/** Snapshot of the previous display theme */
|
||||
displayUserTheme: DisplaySettings | null;
|
||||
/** Pinned provider override for display */
|
||||
pinnedProvider: ProviderName | null;
|
||||
/** Keybinding settings (changes require pi restart) */
|
||||
keybindings: KeybindingSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current settings version
|
||||
*/
|
||||
export const SETTINGS_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
export function getDefaultSettings(): Settings {
|
||||
return {
|
||||
version: SETTINGS_VERSION,
|
||||
providers: {
|
||||
anthropic: {
|
||||
showStatus: true,
|
||||
windows: {
|
||||
show5h: true,
|
||||
show7d: true,
|
||||
showExtra: false,
|
||||
},
|
||||
},
|
||||
copilot: {
|
||||
showStatus: true,
|
||||
showMultiplier: true,
|
||||
showRequestsLeft: true,
|
||||
quotaDisplay: "percentage",
|
||||
windows: {
|
||||
showMonth: true,
|
||||
},
|
||||
},
|
||||
gemini: {
|
||||
showStatus: true,
|
||||
windows: {
|
||||
showPro: true,
|
||||
showFlash: true,
|
||||
},
|
||||
},
|
||||
antigravity: {
|
||||
showStatus: true,
|
||||
showCurrentModel: true,
|
||||
showScopedModels: true,
|
||||
windows: {
|
||||
showModels: true,
|
||||
},
|
||||
modelVisibility: {},
|
||||
modelOrder: [],
|
||||
},
|
||||
codex: {
|
||||
showStatus: true,
|
||||
invertUsage: false,
|
||||
windows: {
|
||||
showPrimary: true,
|
||||
showSecondary: true,
|
||||
},
|
||||
},
|
||||
kiro: {
|
||||
showStatus: false,
|
||||
windows: {
|
||||
showCredits: true,
|
||||
},
|
||||
},
|
||||
zai: {
|
||||
showStatus: false,
|
||||
windows: {
|
||||
showTokens: true,
|
||||
showMonthly: true,
|
||||
},
|
||||
},
|
||||
"opencode-go": {
|
||||
showStatus: false,
|
||||
},
|
||||
},
|
||||
display: {
|
||||
alignment: "split",
|
||||
barStyle: "bar",
|
||||
barType: "horizontal-bar",
|
||||
barWidth: "fill",
|
||||
barCharacter: "light",
|
||||
containBar: false,
|
||||
brailleFillEmpty: false,
|
||||
brailleFullBlocks: false,
|
||||
colorScheme: "base-warning-error",
|
||||
usageColorTargets: {
|
||||
title: true,
|
||||
timer: true,
|
||||
bar: true,
|
||||
usageLabel: true,
|
||||
status: true,
|
||||
},
|
||||
resetTimePosition: "front",
|
||||
resetTimeFormat: "relative",
|
||||
resetTimeContainment: "blank",
|
||||
statusIndicatorMode: "icon",
|
||||
statusIconPack: "emoji",
|
||||
statusIconCustom: "✓⚠×?",
|
||||
statusProviderDivider: false,
|
||||
statusDismissOk: true,
|
||||
showProviderName: false,
|
||||
providerLabel: "none",
|
||||
providerLabelColon: false,
|
||||
providerLabelBold: true,
|
||||
baseTextColor: "muted",
|
||||
backgroundColor: "text",
|
||||
showWindowTitle: true,
|
||||
boldWindowTitle: true,
|
||||
showUsageLabels: true,
|
||||
dividerCharacter: "│",
|
||||
dividerColor: "dim",
|
||||
dividerBlanks: 1,
|
||||
showProviderDivider: true,
|
||||
dividerFooterJoin: true,
|
||||
showTopDivider: false,
|
||||
showBottomDivider: true,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
widgetPlacement: "belowEditor",
|
||||
showContextBar: false,
|
||||
errorThreshold: 25,
|
||||
warningThreshold: 50,
|
||||
overflow: "wrap",
|
||||
successThreshold: 75,
|
||||
},
|
||||
|
||||
displayThemes: [],
|
||||
displayUserTheme: null,
|
||||
pinnedProvider: null,
|
||||
|
||||
keybindings: {
|
||||
cycleProvider: "ctrl+alt+p",
|
||||
toggleResetFormat: "ctrl+alt+r",
|
||||
},
|
||||
|
||||
behavior: {
|
||||
refreshInterval: 60,
|
||||
minRefreshInterval: 10,
|
||||
refreshOnTurnStart: false,
|
||||
refreshOnToolResult: false,
|
||||
},
|
||||
statusRefresh: {
|
||||
refreshInterval: 60,
|
||||
minRefreshInterval: 10,
|
||||
refreshOnTurnStart: false,
|
||||
refreshOnToolResult: false,
|
||||
},
|
||||
providerOrder: [...PROVIDERS],
|
||||
defaultProvider: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects
|
||||
*/
|
||||
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
|
||||
const result = { ...target };
|
||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = target[key];
|
||||
if (
|
||||
sourceValue !== undefined &&
|
||||
typeof sourceValue === "object" &&
|
||||
sourceValue !== null &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
typeof targetValue === "object" &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(targetValue, sourceValue as Partial<typeof targetValue>);
|
||||
} else if (sourceValue !== undefined) {
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge settings with defaults (no legacy migrations).
|
||||
*/
|
||||
export function mergeSettings(loaded: Partial<Settings>): Settings {
|
||||
const migrated = migrateSettings(loaded);
|
||||
return deepMerge(getDefaultSettings(), migrated);
|
||||
}
|
||||
|
||||
function migrateDisplaySettings(display?: Partial<DisplaySettings> | null): void {
|
||||
if (!display) return;
|
||||
const displayAny = display as Partial<DisplaySettings> & { widgetWrapping?: OverflowMode; paddingX?: number };
|
||||
if (displayAny.widgetWrapping !== undefined && displayAny.overflow === undefined) {
|
||||
displayAny.overflow = displayAny.widgetWrapping;
|
||||
}
|
||||
if (displayAny.paddingX !== undefined) {
|
||||
if (displayAny.paddingLeft === undefined) {
|
||||
displayAny.paddingLeft = displayAny.paddingX;
|
||||
}
|
||||
if (displayAny.paddingRight === undefined) {
|
||||
displayAny.paddingRight = displayAny.paddingX;
|
||||
}
|
||||
delete (displayAny as { paddingX?: unknown }).paddingX;
|
||||
}
|
||||
if ("widgetWrapping" in displayAny) {
|
||||
delete (displayAny as { widgetWrapping?: unknown }).widgetWrapping;
|
||||
}
|
||||
}
|
||||
|
||||
function migrateSettings(loaded: Partial<Settings>): Partial<Settings> {
|
||||
migrateDisplaySettings(loaded.display);
|
||||
migrateDisplaySettings(loaded.displayUserTheme);
|
||||
if (Array.isArray(loaded.displayThemes)) {
|
||||
for (const theme of loaded.displayThemes) {
|
||||
migrateDisplaySettings(theme.display as Partial<DisplaySettings> | undefined);
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Settings UI entry point (re-export).
|
||||
*/
|
||||
|
||||
export { showSettingsUI } from "./settings/ui.js";
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* Settings persistence for sub-bar
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Settings } from "./settings-types.js";
|
||||
import { getDefaultSettings, mergeSettings } from "./settings-types.js";
|
||||
import { getStorage } from "./storage.js";
|
||||
import { getLegacySettingsPath, getSettingsPath } from "./paths.js";
|
||||
|
||||
/**
|
||||
* Settings file path
|
||||
*/
|
||||
export const SETTINGS_PATH = getSettingsPath();
|
||||
const LEGACY_SETTINGS_PATH = getLegacySettingsPath();
|
||||
|
||||
/**
|
||||
* In-memory settings cache
|
||||
*/
|
||||
let cachedSettings: Settings | undefined;
|
||||
|
||||
/**
|
||||
* Ensure the settings directory exists
|
||||
*/
|
||||
function ensureSettingsDir(): void {
|
||||
const storage = getStorage();
|
||||
const dir = path.dirname(SETTINGS_PATH);
|
||||
storage.ensureDir(dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse settings file contents
|
||||
*/
|
||||
function parseSettings(content: string): Settings {
|
||||
const loaded = JSON.parse(content) as Partial<Settings>;
|
||||
return mergeSettings({
|
||||
version: loaded.version,
|
||||
display: loaded.display,
|
||||
providers: loaded.providers,
|
||||
displayThemes: loaded.displayThemes,
|
||||
displayUserTheme: loaded.displayUserTheme,
|
||||
pinnedProvider: loaded.pinnedProvider,
|
||||
keybindings: loaded.keybindings,
|
||||
} as Partial<Settings>);
|
||||
}
|
||||
|
||||
function loadSettingsFromDisk(settingsPath: string): Settings | null {
|
||||
const storage = getStorage();
|
||||
if (storage.exists(settingsPath)) {
|
||||
const content = storage.readFile(settingsPath);
|
||||
if (content) {
|
||||
return parseSettings(content);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tryLoadSettings(settingsPath: string): Settings | null {
|
||||
try {
|
||||
return loadSettingsFromDisk(settingsPath);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load settings from ${settingsPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from disk
|
||||
*/
|
||||
export function loadSettings(): Settings {
|
||||
if (cachedSettings) {
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
const diskSettings = tryLoadSettings(SETTINGS_PATH);
|
||||
if (diskSettings) {
|
||||
cachedSettings = diskSettings;
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH);
|
||||
if (legacySettings) {
|
||||
const saved = saveSettings(legacySettings);
|
||||
if (saved) {
|
||||
getStorage().removeFile(LEGACY_SETTINGS_PATH);
|
||||
}
|
||||
cachedSettings = legacySettings;
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
// Return defaults if file doesn't exist or failed to load
|
||||
cachedSettings = getDefaultSettings();
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to disk
|
||||
*/
|
||||
export function saveSettings(settings: Settings): boolean {
|
||||
const storage = getStorage();
|
||||
try {
|
||||
ensureSettingsDir();
|
||||
let next = settings;
|
||||
if (cachedSettings) {
|
||||
const diskSettings = loadSettingsFromDisk(SETTINGS_PATH);
|
||||
if (diskSettings) {
|
||||
const displayChanged = JSON.stringify(settings.display) !== JSON.stringify(cachedSettings.display);
|
||||
const providersChanged = JSON.stringify(settings.providers) !== JSON.stringify(cachedSettings.providers);
|
||||
const themesChanged = JSON.stringify(settings.displayThemes) !== JSON.stringify(cachedSettings.displayThemes);
|
||||
const userThemeChanged = JSON.stringify(settings.displayUserTheme) !== JSON.stringify(cachedSettings.displayUserTheme);
|
||||
const pinnedChanged = settings.pinnedProvider !== cachedSettings.pinnedProvider;
|
||||
const keybindingsChanged = JSON.stringify(settings.keybindings) !== JSON.stringify(cachedSettings.keybindings);
|
||||
|
||||
next = {
|
||||
...diskSettings,
|
||||
version: settings.version,
|
||||
display: displayChanged ? settings.display : diskSettings.display,
|
||||
providers: providersChanged ? settings.providers : diskSettings.providers,
|
||||
displayThemes: themesChanged ? settings.displayThemes : diskSettings.displayThemes,
|
||||
displayUserTheme: userThemeChanged ? settings.displayUserTheme : diskSettings.displayUserTheme,
|
||||
pinnedProvider: pinnedChanged ? settings.pinnedProvider : diskSettings.pinnedProvider,
|
||||
keybindings: keybindingsChanged ? settings.keybindings : diskSettings.keybindings,
|
||||
};
|
||||
}
|
||||
}
|
||||
const content = JSON.stringify({
|
||||
version: next.version,
|
||||
display: next.display,
|
||||
providers: next.providers,
|
||||
displayThemes: next.displayThemes,
|
||||
displayUserTheme: next.displayUserTheme,
|
||||
pinnedProvider: next.pinnedProvider,
|
||||
keybindings: next.keybindings,
|
||||
}, null, 2);
|
||||
storage.writeFile(SETTINGS_PATH, content);
|
||||
cachedSettings = next;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset settings to defaults
|
||||
*/
|
||||
export function resetSettings(): Settings {
|
||||
const defaults = getDefaultSettings();
|
||||
const current = getSettings();
|
||||
const next = {
|
||||
...current,
|
||||
display: defaults.display,
|
||||
providers: defaults.providers,
|
||||
displayThemes: defaults.displayThemes,
|
||||
displayUserTheme: defaults.displayUserTheme,
|
||||
pinnedProvider: defaults.pinnedProvider,
|
||||
keybindings: defaults.keybindings,
|
||||
version: defaults.version,
|
||||
};
|
||||
saveSettings(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current settings (cached)
|
||||
*/
|
||||
export function getSettings(): Settings {
|
||||
return loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the settings cache (force reload on next access)
|
||||
*/
|
||||
export function clearSettingsCache(): void {
|
||||
cachedSettings = undefined;
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
/**
|
||||
* Display settings UI helpers.
|
||||
*/
|
||||
|
||||
import type { SettingItem } from "@mariozechner/pi-tui";
|
||||
import type {
|
||||
Settings,
|
||||
BarStyle,
|
||||
BarType,
|
||||
ColorScheme,
|
||||
BarCharacter,
|
||||
DividerCharacter,
|
||||
WidgetWrapping,
|
||||
DisplayAlignment,
|
||||
BarWidth,
|
||||
DividerBlanks,
|
||||
ProviderLabel,
|
||||
BaseTextColor,
|
||||
ResetTimeFormat,
|
||||
ResetTimerContainment,
|
||||
StatusIndicatorMode,
|
||||
StatusIconPack,
|
||||
DividerColor,
|
||||
UsageColorTargets,
|
||||
} from "../settings-types.js";
|
||||
import {
|
||||
BASE_COLOR_OPTIONS,
|
||||
DIVIDER_COLOR_OPTIONS,
|
||||
normalizeBaseTextColor,
|
||||
normalizeDividerColor,
|
||||
} from "../settings-types.js";
|
||||
import { CUSTOM_OPTION } from "../ui/settings-list.js";
|
||||
|
||||
export function buildDisplayLayoutItems(settings: Settings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "showContextBar",
|
||||
label: "Show Context Bar",
|
||||
currentValue: settings.display.showContextBar ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show context window usage as leftmost progress bar.",
|
||||
},
|
||||
{
|
||||
id: "alignment",
|
||||
label: "Alignment",
|
||||
currentValue: settings.display.alignment,
|
||||
values: ["left", "center", "right", "split"] as DisplayAlignment[],
|
||||
description: "Align the usage line inside the widget.",
|
||||
},
|
||||
{
|
||||
id: "overflow",
|
||||
label: "Overflow",
|
||||
currentValue: settings.display.overflow,
|
||||
values: ["truncate", "wrap"] as WidgetWrapping[],
|
||||
description: "Wrap the usage line or truncate with ellipsis (requires bar width ≠ fill and alignment ≠ split).",
|
||||
},
|
||||
{
|
||||
id: "paddingLeft",
|
||||
label: "Padding Left",
|
||||
currentValue: String(settings.display.paddingLeft ?? 0),
|
||||
values: ["0", "1", "2", "3", "4", CUSTOM_OPTION],
|
||||
description: "Add left padding inside the widget.",
|
||||
},
|
||||
{
|
||||
id: "paddingRight",
|
||||
label: "Padding Right",
|
||||
currentValue: String(settings.display.paddingRight ?? 0),
|
||||
values: ["0", "1", "2", "3", "4", CUSTOM_OPTION],
|
||||
description: "Add right padding inside the widget.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDisplayResetItems(settings: Settings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "resetTimePosition",
|
||||
label: "Reset Timer",
|
||||
currentValue: settings.display.resetTimePosition,
|
||||
values: ["off", "front", "back", "integrated"],
|
||||
description: "Where to show the reset timer in each window.",
|
||||
},
|
||||
{
|
||||
id: "resetTimeFormat",
|
||||
label: "Reset Timer Format",
|
||||
currentValue: settings.display.resetTimeFormat ?? "relative",
|
||||
values: ["relative", "datetime"] as ResetTimeFormat[],
|
||||
description: "Show relative countdown or reset datetime.",
|
||||
},
|
||||
{
|
||||
id: "resetTimeContainment",
|
||||
label: "Reset Timer Containment",
|
||||
currentValue: settings.display.resetTimeContainment ?? "()",
|
||||
values: ["none", "blank", "()", "[]", "<>", CUSTOM_OPTION] as ResetTimerContainment[],
|
||||
description: "Wrapping characters for the reset timer (custom supported).",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveUsageColorTargets(targets?: UsageColorTargets): UsageColorTargets {
|
||||
return {
|
||||
title: targets?.title ?? true,
|
||||
timer: targets?.timer ?? true,
|
||||
bar: targets?.bar ?? true,
|
||||
usageLabel: targets?.usageLabel ?? true,
|
||||
status: targets?.status ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatUsageColorTargetsSummary(targets?: UsageColorTargets): string {
|
||||
const resolved = resolveUsageColorTargets(targets);
|
||||
const enabled = [
|
||||
resolved.title ? "Title" : null,
|
||||
resolved.timer ? "Timer" : null,
|
||||
resolved.bar ? "Bar" : null,
|
||||
resolved.usageLabel ? "Usage label" : null,
|
||||
resolved.status ? "Status" : null,
|
||||
].filter(Boolean) as string[];
|
||||
if (enabled.length === 0) return "off";
|
||||
if (enabled.length === 5) return "all";
|
||||
return enabled.join(", ");
|
||||
}
|
||||
|
||||
export function buildUsageColorTargetItems(settings: Settings): SettingItem[] {
|
||||
const targets = resolveUsageColorTargets(settings.display.usageColorTargets);
|
||||
return [
|
||||
{
|
||||
id: "usageColorTitle",
|
||||
label: "Title",
|
||||
currentValue: targets.title ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Color the window title by usage.",
|
||||
},
|
||||
{
|
||||
id: "usageColorTimer",
|
||||
label: "Timer",
|
||||
currentValue: targets.timer ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Color the reset timer by usage.",
|
||||
},
|
||||
{
|
||||
id: "usageColorBar",
|
||||
label: "Bar",
|
||||
currentValue: targets.bar ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Color the usage bar by usage.",
|
||||
},
|
||||
{
|
||||
id: "usageColorLabel",
|
||||
label: "Usage label",
|
||||
currentValue: targets.usageLabel ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Color the percentage text by usage.",
|
||||
},
|
||||
{
|
||||
id: "usageColorStatus",
|
||||
label: "Status",
|
||||
currentValue: targets.status ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Color the status indicator by status.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDisplayColorItems(settings: Settings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "baseTextColor",
|
||||
label: "Base Color",
|
||||
currentValue: normalizeBaseTextColor(settings.display.baseTextColor),
|
||||
values: [...BASE_COLOR_OPTIONS] as BaseTextColor[],
|
||||
description: "Base color for neutral labels and dividers.",
|
||||
},
|
||||
{
|
||||
id: "backgroundColor",
|
||||
label: "Background Color",
|
||||
currentValue: normalizeBaseTextColor(settings.display.backgroundColor),
|
||||
values: [...BASE_COLOR_OPTIONS] as BaseTextColor[],
|
||||
description: "Background color for the widget line.",
|
||||
},
|
||||
{
|
||||
id: "colorScheme",
|
||||
label: "Color Indicator Scheme",
|
||||
currentValue: settings.display.colorScheme,
|
||||
values: [
|
||||
"base-warning-error",
|
||||
"success-base-warning-error",
|
||||
"monochrome",
|
||||
] as ColorScheme[],
|
||||
description: "Choose how usage/status indicators are color-coded.",
|
||||
},
|
||||
{
|
||||
id: "usageColorTargets",
|
||||
label: "Color Indicator Targets",
|
||||
currentValue: formatUsageColorTargetsSummary(settings.display.usageColorTargets),
|
||||
description: "Pick which elements use the indicator colors.",
|
||||
},
|
||||
{
|
||||
id: "errorThreshold",
|
||||
label: "Error Threshold (%)",
|
||||
currentValue: String(settings.display.errorThreshold),
|
||||
values: ["10", "15", "20", "25", "30", "35", "40", CUSTOM_OPTION],
|
||||
description: "Percent remaining below which usage is red.",
|
||||
},
|
||||
{
|
||||
id: "warningThreshold",
|
||||
label: "Warning Threshold (%)",
|
||||
currentValue: String(settings.display.warningThreshold),
|
||||
values: ["30", "40", "50", "60", "70", CUSTOM_OPTION],
|
||||
description: "Percent remaining below which usage is yellow.",
|
||||
},
|
||||
{
|
||||
id: "successThreshold",
|
||||
label: "Success Threshold (%)",
|
||||
currentValue: String(settings.display.successThreshold),
|
||||
values: ["60", "70", "75", "80", "90", CUSTOM_OPTION],
|
||||
description: "Percent remaining above which usage is green.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDisplayBarItems(settings: Settings): SettingItem[] {
|
||||
const items: SettingItem[] = [
|
||||
{
|
||||
id: "barType",
|
||||
label: "Bar Type",
|
||||
currentValue: settings.display.barType,
|
||||
values: [
|
||||
"horizontal-bar",
|
||||
"horizontal-single",
|
||||
"vertical",
|
||||
"braille",
|
||||
"shade",
|
||||
] as BarType[],
|
||||
description: "Choose the bar glyph style for usage.",
|
||||
},
|
||||
];
|
||||
|
||||
if (settings.display.barType === "horizontal-bar") {
|
||||
items.push({
|
||||
id: "barCharacter",
|
||||
label: "H. Bar Character",
|
||||
currentValue: settings.display.barCharacter,
|
||||
values: ["light", "heavy", "double", "block", CUSTOM_OPTION],
|
||||
description: "Custom bar character(s), set 1 or 2 (fill/empty)",
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
id: "barWidth",
|
||||
label: "Bar Width",
|
||||
currentValue: String(settings.display.barWidth),
|
||||
values: ["1", "4", "6", "8", "10", "12", "fill", CUSTOM_OPTION],
|
||||
description: "Set the bar width or fill available space.",
|
||||
},
|
||||
{
|
||||
id: "containBar",
|
||||
label: "Contain Bar",
|
||||
currentValue: settings.display.containBar ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Wrap the bar with ▕ and ▏ caps.",
|
||||
},
|
||||
);
|
||||
|
||||
if (settings.display.barType === "braille") {
|
||||
items.push(
|
||||
{
|
||||
id: "brailleFillEmpty",
|
||||
label: "Braille Empty Fill",
|
||||
currentValue: settings.display.brailleFillEmpty ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Fill empty braille cells with dim blocks.",
|
||||
},
|
||||
{
|
||||
id: "brailleFullBlocks",
|
||||
label: "Braille Full Blocks",
|
||||
currentValue: settings.display.brailleFullBlocks ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Use full 8-dot braille blocks for filled segments.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
items.push({
|
||||
id: "barStyle",
|
||||
label: "Bar Style",
|
||||
currentValue: settings.display.barStyle,
|
||||
values: ["bar", "percentage", "both"] as BarStyle[],
|
||||
description: "Show bar, percentage, or both.",
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildDisplayProviderItems(settings: Settings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "showProviderName",
|
||||
label: "Show Provider Name",
|
||||
currentValue: settings.display.showProviderName ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Toggle the provider name prefix.",
|
||||
},
|
||||
{
|
||||
id: "providerLabel",
|
||||
label: "Provider Label",
|
||||
currentValue: settings.display.providerLabel,
|
||||
values: ["none", "plan", "subscription", "sub", CUSTOM_OPTION] as (ProviderLabel | typeof CUSTOM_OPTION)[],
|
||||
description: "Suffix appended after the provider name.",
|
||||
},
|
||||
{
|
||||
id: "providerLabelColon",
|
||||
label: "Provider Label Colon",
|
||||
currentValue: settings.display.providerLabelColon ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show a colon after the provider label.",
|
||||
},
|
||||
{
|
||||
id: "providerLabelBold",
|
||||
label: "Show in Bold",
|
||||
currentValue: settings.display.providerLabelBold ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Bold the provider name and colon.",
|
||||
},
|
||||
{
|
||||
id: "showUsageLabels",
|
||||
label: "Show Usage Labels",
|
||||
currentValue: settings.display.showUsageLabels ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show “used/rem.” labels after percentages.",
|
||||
},
|
||||
{
|
||||
id: "showWindowTitle",
|
||||
label: "Show Title",
|
||||
currentValue: settings.display.showWindowTitle ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show window titles like 5h, Week, etc.",
|
||||
},
|
||||
{
|
||||
id: "boldWindowTitle",
|
||||
label: "Bold Title",
|
||||
currentValue: settings.display.boldWindowTitle ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Bold window titles like 5h, Week, etc.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const STATUS_ICON_PACK_PREVIEW = {
|
||||
minimal: "minimal (✓ ⚠ × ?)",
|
||||
emoji: "emoji (✅ ⚠️ 🔴 ❓)",
|
||||
faces: "faces (😎 😳 😵 🤔)",
|
||||
} as const;
|
||||
|
||||
const STATUS_ICON_FACES_PRESET = "😎😳😵🤔";
|
||||
|
||||
const STATUS_ICON_CUSTOM_FALLBACK = ["✓", "⚠", "×", "?"];
|
||||
const STATUS_ICON_CUSTOM_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
function resolveCustomStatusIcons(value?: string): [string, string, string, string] {
|
||||
if (!value) return STATUS_ICON_CUSTOM_FALLBACK as [string, string, string, string];
|
||||
const segments = Array.from(STATUS_ICON_CUSTOM_SEGMENTER.segment(value), (entry) => entry.segment)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
if (segments.length < 3) return STATUS_ICON_CUSTOM_FALLBACK as [string, string, string, string];
|
||||
if (segments.length === 3) {
|
||||
return [segments[0], segments[1], segments[2], STATUS_ICON_CUSTOM_FALLBACK[3]] as [string, string, string, string];
|
||||
}
|
||||
return [segments[0], segments[1], segments[2], segments[3]] as [string, string, string, string];
|
||||
}
|
||||
|
||||
function formatCustomStatusIcons(value?: string): string {
|
||||
return resolveCustomStatusIcons(value).join(" ");
|
||||
}
|
||||
|
||||
function formatStatusIconPack(pack: Exclude<StatusIconPack, "custom">): string {
|
||||
return STATUS_ICON_PACK_PREVIEW[pack] ?? pack;
|
||||
}
|
||||
|
||||
function parseStatusIconPack(value: string): StatusIconPack {
|
||||
if (value.startsWith("minimal")) return "minimal";
|
||||
if (value.startsWith("emoji")) return "emoji";
|
||||
return "emoji";
|
||||
}
|
||||
|
||||
export function buildDisplayStatusItems(settings: Settings): SettingItem[] {
|
||||
const rawMode = settings.display.statusIndicatorMode ?? "icon";
|
||||
const mode: StatusIndicatorMode = rawMode === "text" || rawMode === "icon+text" || rawMode === "icon"
|
||||
? rawMode
|
||||
: "icon";
|
||||
const items: SettingItem[] = [
|
||||
{
|
||||
id: "statusIndicatorMode",
|
||||
label: "Status Mode",
|
||||
currentValue: mode,
|
||||
values: ["icon", "text", "icon+text"] as StatusIndicatorMode[],
|
||||
description: "Use icons, text, or both for status indicators.",
|
||||
},
|
||||
];
|
||||
|
||||
if (mode === "icon" || mode === "icon+text") {
|
||||
const pack = settings.display.statusIconPack ?? "emoji";
|
||||
const customIcons = settings.display.statusIconCustom;
|
||||
items.push({
|
||||
id: "statusIconPack",
|
||||
label: "Status Icon Pack",
|
||||
currentValue: pack === "custom" ? formatCustomStatusIcons(customIcons) : formatStatusIconPack(pack),
|
||||
values: [
|
||||
formatStatusIconPack("minimal"),
|
||||
formatStatusIconPack("emoji"),
|
||||
STATUS_ICON_PACK_PREVIEW.faces,
|
||||
CUSTOM_OPTION,
|
||||
],
|
||||
description: "Pick the icon set used for status indicators. Choose custom to edit icons (OK/warn/error/unknown).",
|
||||
});
|
||||
}
|
||||
|
||||
items.push(
|
||||
{
|
||||
id: "statusDismissOk",
|
||||
label: "Dismiss Operational Status",
|
||||
currentValue: settings.display.statusDismissOk ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Hide status indicators when there are no incidents.",
|
||||
}
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildDisplayDividerItems(settings: Settings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "dividerCharacter",
|
||||
label: "Divider Character",
|
||||
currentValue: settings.display.dividerCharacter,
|
||||
values: ["none", "blank", "|", "│", "┃", "┆", "┇", "║", "•", "●", "○", "◇", CUSTOM_OPTION] as DividerCharacter[],
|
||||
description: "Choose the divider glyph between windows.",
|
||||
},
|
||||
{
|
||||
id: "dividerColor",
|
||||
label: "Divider Color",
|
||||
currentValue: normalizeDividerColor(settings.display.dividerColor ?? "borderMuted"),
|
||||
values: [...DIVIDER_COLOR_OPTIONS] as DividerColor[],
|
||||
description: "Color used for divider glyphs and lines.",
|
||||
},
|
||||
{
|
||||
id: "statusProviderDivider",
|
||||
label: "Status/Provider Divider",
|
||||
currentValue: settings.display.statusProviderDivider ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Add a divider between status and provider label.",
|
||||
},
|
||||
{
|
||||
id: "dividerBlanks",
|
||||
label: "Blanks Before/After Divider",
|
||||
currentValue: String(settings.display.dividerBlanks),
|
||||
values: ["0", "1", "2", "3", "fill", CUSTOM_OPTION],
|
||||
description: "Padding around the divider character.",
|
||||
},
|
||||
{
|
||||
id: "showProviderDivider",
|
||||
label: "Show Provider Divider",
|
||||
currentValue: settings.display.showProviderDivider ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show the divider after the provider label.",
|
||||
},
|
||||
{
|
||||
id: "showTopDivider",
|
||||
label: "Show Top Divider",
|
||||
currentValue: settings.display.showTopDivider ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show a divider line above the widget.",
|
||||
},
|
||||
{
|
||||
id: "showBottomDivider",
|
||||
label: "Show Bottom Divider",
|
||||
currentValue: settings.display.showBottomDivider ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Show a divider line below the widget.",
|
||||
},
|
||||
{
|
||||
id: "dividerFooterJoin",
|
||||
label: "Connect Dividers",
|
||||
currentValue: settings.display.dividerFooterJoin ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Draw reverse-T connectors for top/bottom dividers.",
|
||||
},
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function parseClampedNumber(value: string, min: number, max: number): number | null {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clampNumber(parsed, min, max);
|
||||
}
|
||||
|
||||
export function applyDisplayChange(settings: Settings, id: string, value: string): Settings {
|
||||
switch (id) {
|
||||
case "alignment":
|
||||
settings.display.alignment = value as DisplayAlignment;
|
||||
break;
|
||||
case "barType":
|
||||
settings.display.barType = value as BarType;
|
||||
break;
|
||||
case "barStyle":
|
||||
settings.display.barStyle = value as BarStyle;
|
||||
break;
|
||||
case "barWidth": {
|
||||
if (value === "fill") {
|
||||
settings.display.barWidth = "fill" as BarWidth;
|
||||
break;
|
||||
}
|
||||
const parsed = parseClampedNumber(value, 0, 100);
|
||||
if (parsed !== null) {
|
||||
settings.display.barWidth = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "containBar":
|
||||
settings.display.containBar = value === "on";
|
||||
break;
|
||||
case "barCharacter":
|
||||
settings.display.barCharacter = value as BarCharacter;
|
||||
break;
|
||||
case "brailleFillEmpty":
|
||||
settings.display.brailleFillEmpty = value === "on";
|
||||
break;
|
||||
case "brailleFullBlocks":
|
||||
settings.display.brailleFullBlocks = value === "on";
|
||||
break;
|
||||
case "colorScheme":
|
||||
settings.display.colorScheme = value as ColorScheme;
|
||||
break;
|
||||
case "usageColorTitle":
|
||||
settings.display.usageColorTargets = {
|
||||
...resolveUsageColorTargets(settings.display.usageColorTargets),
|
||||
title: value === "on",
|
||||
};
|
||||
break;
|
||||
case "usageColorTimer":
|
||||
settings.display.usageColorTargets = {
|
||||
...resolveUsageColorTargets(settings.display.usageColorTargets),
|
||||
timer: value === "on",
|
||||
};
|
||||
break;
|
||||
case "usageColorBar":
|
||||
settings.display.usageColorTargets = {
|
||||
...resolveUsageColorTargets(settings.display.usageColorTargets),
|
||||
bar: value === "on",
|
||||
};
|
||||
break;
|
||||
case "usageColorLabel":
|
||||
settings.display.usageColorTargets = {
|
||||
...resolveUsageColorTargets(settings.display.usageColorTargets),
|
||||
usageLabel: value === "on",
|
||||
};
|
||||
break;
|
||||
case "usageColorStatus":
|
||||
settings.display.usageColorTargets = {
|
||||
...resolveUsageColorTargets(settings.display.usageColorTargets),
|
||||
status: value === "on",
|
||||
};
|
||||
break;
|
||||
case "usageColorTargets":
|
||||
settings.display.usageColorTargets = resolveUsageColorTargets(settings.display.usageColorTargets);
|
||||
break;
|
||||
case "resetTimePosition":
|
||||
settings.display.resetTimePosition = value as "off" | "front" | "back" | "integrated";
|
||||
break;
|
||||
case "resetTimeFormat":
|
||||
settings.display.resetTimeFormat = value as ResetTimeFormat;
|
||||
break;
|
||||
case "resetTimeContainment":
|
||||
if (value === CUSTOM_OPTION) {
|
||||
break;
|
||||
}
|
||||
settings.display.resetTimeContainment = value as ResetTimerContainment;
|
||||
break;
|
||||
case "statusIndicatorMode":
|
||||
settings.display.statusIndicatorMode = value as StatusIndicatorMode;
|
||||
break;
|
||||
case "statusIconPack":
|
||||
if (value === CUSTOM_OPTION) {
|
||||
settings.display.statusIconPack = "custom";
|
||||
break;
|
||||
}
|
||||
if (value.startsWith("minimal") || value.startsWith("emoji")) {
|
||||
settings.display.statusIconPack = parseStatusIconPack(value);
|
||||
break;
|
||||
}
|
||||
if (value.startsWith("faces")) {
|
||||
settings.display.statusIconCustom = STATUS_ICON_FACES_PRESET;
|
||||
settings.display.statusIconPack = "custom";
|
||||
break;
|
||||
}
|
||||
settings.display.statusIconCustom = value;
|
||||
settings.display.statusIconPack = "custom";
|
||||
break;
|
||||
case "statusIconCustom":
|
||||
settings.display.statusIconCustom = value;
|
||||
settings.display.statusIconPack = "custom";
|
||||
break;
|
||||
case "statusProviderDivider":
|
||||
settings.display.statusProviderDivider = value === "on";
|
||||
break;
|
||||
case "statusDismissOk":
|
||||
settings.display.statusDismissOk = value === "on";
|
||||
break;
|
||||
case "showProviderName":
|
||||
settings.display.showProviderName = value === "on";
|
||||
break;
|
||||
case "providerLabel":
|
||||
settings.display.providerLabel = value as ProviderLabel;
|
||||
break;
|
||||
case "providerLabelColon":
|
||||
settings.display.providerLabelColon = value === "on";
|
||||
break;
|
||||
case "providerLabelBold":
|
||||
settings.display.providerLabelBold = value === "on";
|
||||
break;
|
||||
case "baseTextColor":
|
||||
settings.display.baseTextColor = normalizeBaseTextColor(value);
|
||||
break;
|
||||
case "backgroundColor":
|
||||
settings.display.backgroundColor = normalizeBaseTextColor(value);
|
||||
break;
|
||||
case "showUsageLabels":
|
||||
settings.display.showUsageLabels = value === "on";
|
||||
break;
|
||||
case "showWindowTitle":
|
||||
settings.display.showWindowTitle = value === "on";
|
||||
break;
|
||||
case "boldWindowTitle":
|
||||
settings.display.boldWindowTitle = value === "on";
|
||||
break;
|
||||
case "showContextBar":
|
||||
settings.display.showContextBar = value === "on";
|
||||
break;
|
||||
case "paddingLeft": {
|
||||
const parsed = parseClampedNumber(value, 0, 100);
|
||||
if (parsed !== null) {
|
||||
settings.display.paddingLeft = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "paddingRight": {
|
||||
const parsed = parseClampedNumber(value, 0, 100);
|
||||
if (parsed !== null) {
|
||||
settings.display.paddingRight = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "dividerCharacter":
|
||||
settings.display.dividerCharacter = value as DividerCharacter;
|
||||
break;
|
||||
case "dividerColor":
|
||||
settings.display.dividerColor = normalizeDividerColor(value);
|
||||
break;
|
||||
case "dividerBlanks": {
|
||||
if (value === "fill") {
|
||||
settings.display.dividerBlanks = "fill" as DividerBlanks;
|
||||
break;
|
||||
}
|
||||
const parsed = parseClampedNumber(value, 0, 100);
|
||||
if (parsed !== null) {
|
||||
settings.display.dividerBlanks = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "showProviderDivider":
|
||||
settings.display.showProviderDivider = value === "on";
|
||||
break;
|
||||
case "dividerFooterJoin":
|
||||
settings.display.dividerFooterJoin = value === "on";
|
||||
break;
|
||||
case "showTopDivider":
|
||||
settings.display.showTopDivider = value === "on";
|
||||
break;
|
||||
case "showBottomDivider":
|
||||
settings.display.showBottomDivider = value === "on";
|
||||
break;
|
||||
case "overflow":
|
||||
settings.display.overflow = value as WidgetWrapping;
|
||||
break;
|
||||
case "widgetWrapping":
|
||||
settings.display.overflow = value as WidgetWrapping;
|
||||
break;
|
||||
case "errorThreshold": {
|
||||
const parsed = parseClampedNumber(value, 0, 100);
|
||||
if (parsed !== null) {
|
||||
settings.display.errorThreshold = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "warningThreshold": {
|
||||
const parsed = parseClampedNumber(value, 0, 100);
|
||||
if (parsed !== null) {
|
||||
settings.display.warningThreshold = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "successThreshold": {
|
||||
const parsed = parseClampedNumber(value, 0, 100);
|
||||
if (parsed !== null) {
|
||||
settings.display.successThreshold = parsed;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
/**
|
||||
* Settings menu item builders.
|
||||
*/
|
||||
|
||||
import type { SelectItem } from "@mariozechner/pi-tui";
|
||||
import type { CoreProviderSettingsMap } from "../../shared.js";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import type { ProviderName } from "../types.js";
|
||||
import { PROVIDERS, PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
|
||||
|
||||
export type TooltipSelectItem = SelectItem & { tooltip?: string };
|
||||
|
||||
export function buildMainMenuItems(settings: Settings, pinnedProvider?: ProviderName | null): TooltipSelectItem[] {
|
||||
const pinnedLabel = pinnedProvider ? PROVIDER_DISPLAY_NAMES[pinnedProvider] : "auto (current provider)";
|
||||
const kb = settings.keybindings;
|
||||
const kbDesc = `cycle: ${kb.cycleProvider}, reset: ${kb.toggleResetFormat}`;
|
||||
return [
|
||||
{
|
||||
value: "display-theme",
|
||||
label: "Themes",
|
||||
description: "save, manage, share",
|
||||
tooltip: "Save, load, and share display themes.",
|
||||
},
|
||||
{
|
||||
value: "display",
|
||||
label: "Adv. Display Settings",
|
||||
description: "layout, bars, colors",
|
||||
tooltip: "Adjust layout, colors, bar styling, status indicators, and dividers.",
|
||||
},
|
||||
{
|
||||
value: "providers",
|
||||
label: "Provider Settings",
|
||||
description: "provider specific settings",
|
||||
tooltip: "Configure provider display toggles and window visibility.",
|
||||
},
|
||||
{
|
||||
value: "pin-provider",
|
||||
label: "Provider Shown",
|
||||
description: pinnedLabel,
|
||||
tooltip: "Select which provider is shown in the widget.",
|
||||
},
|
||||
{
|
||||
value: "keybindings",
|
||||
label: "Keybindings",
|
||||
description: kbDesc,
|
||||
tooltip: "Configure keyboard shortcuts. Changes take effect after pi restart.",
|
||||
},
|
||||
{
|
||||
value: "open-core-settings",
|
||||
label: "Additional settings",
|
||||
description: "in /sub-core:settings",
|
||||
tooltip: "Open /sub-core:settings for refresh behavior and provider enablement.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildProviderListItems(settings: Settings, coreProviders?: CoreProviderSettingsMap): TooltipSelectItem[] {
|
||||
const orderedProviders = settings.providerOrder.length > 0 ? settings.providerOrder : PROVIDERS;
|
||||
const items: TooltipSelectItem[] = orderedProviders.map((provider) => {
|
||||
const ps = settings.providers[provider];
|
||||
const core = coreProviders?.[provider];
|
||||
const enabledValue = core
|
||||
? core.enabled === "auto"
|
||||
? "auto"
|
||||
: core.enabled === true || core.enabled === "on"
|
||||
? "on"
|
||||
: "off"
|
||||
: "auto";
|
||||
const status = ps.showStatus ? "status on" : "status off";
|
||||
return {
|
||||
value: `provider-${provider}`,
|
||||
label: PROVIDER_DISPLAY_NAMES[provider],
|
||||
description: `enabled ${enabledValue}, ${status}`,
|
||||
tooltip: `Configure ${PROVIDER_DISPLAY_NAMES[provider]} display settings.`,
|
||||
};
|
||||
});
|
||||
|
||||
items.push({
|
||||
value: "reset-providers",
|
||||
label: "Reset Provider Defaults",
|
||||
description: "restore provider settings",
|
||||
tooltip: "Restore provider display settings to their defaults.",
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function buildDisplayMenuItems(): TooltipSelectItem[] {
|
||||
return [
|
||||
{
|
||||
value: "display-layout",
|
||||
label: "Layout & Structure",
|
||||
description: "alignment, wrapping, padding",
|
||||
tooltip: "Control alignment, wrapping, and padding.",
|
||||
},
|
||||
{
|
||||
value: "display-bar",
|
||||
label: "Bars",
|
||||
description: "style, width, character",
|
||||
tooltip: "Customize bar type, width, and bar styling.",
|
||||
},
|
||||
{
|
||||
value: "display-provider",
|
||||
label: "Labels & Text",
|
||||
description: "labels, titles, usage text",
|
||||
tooltip: "Adjust provider label visibility and text styling.",
|
||||
},
|
||||
{
|
||||
value: "display-reset",
|
||||
label: "Reset Timer",
|
||||
description: "position, format, wrapping",
|
||||
tooltip: "Control reset timer placement and formatting.",
|
||||
},
|
||||
{
|
||||
value: "display-status",
|
||||
label: "Status",
|
||||
description: "mode, icons, text",
|
||||
tooltip: "Configure status mode and icon packs.",
|
||||
},
|
||||
{
|
||||
value: "display-divider",
|
||||
label: "Dividers",
|
||||
description: "character, blanks, status divider, lines",
|
||||
tooltip: "Change divider character, spacing, status separator, and divider lines.",
|
||||
},
|
||||
{
|
||||
value: "display-color",
|
||||
label: "Colors",
|
||||
description: "base, scheme, thresholds",
|
||||
tooltip: "Tune base colors, color scheme, and thresholds.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildDisplayThemeMenuItems(): TooltipSelectItem[] {
|
||||
return [
|
||||
{
|
||||
value: "display-theme-save",
|
||||
label: "Save Theme",
|
||||
description: "store current theme",
|
||||
tooltip: "Save the current display theme with a custom name.",
|
||||
},
|
||||
{
|
||||
value: "display-theme-load",
|
||||
label: "Load & Manage themes",
|
||||
description: "load, share, rename and delete themes",
|
||||
tooltip: "Load, share, delete, rename, and restore saved themes.",
|
||||
},
|
||||
{
|
||||
value: "display-theme-share",
|
||||
label: "Share Theme",
|
||||
description: "share current theme",
|
||||
tooltip: "Post a share string for the current theme.",
|
||||
},
|
||||
{
|
||||
value: "display-theme-import",
|
||||
label: "Import theme",
|
||||
description: "from share string",
|
||||
tooltip: "Import a shared theme string.",
|
||||
},
|
||||
{
|
||||
value: "display-theme-random",
|
||||
label: "Random theme",
|
||||
description: "generate a new theme",
|
||||
tooltip: "Generate a random display theme as inspiration or a starting point.",
|
||||
},
|
||||
{
|
||||
value: "display-theme-restore",
|
||||
label: "Restore previous state",
|
||||
description: "restore your last theme",
|
||||
tooltip: "Restore your previous display theme.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildProviderSettingsItems(settings: Settings): TooltipSelectItem[] {
|
||||
return buildProviderListItems(settings);
|
||||
}
|
||||
|
||||
export function getProviderFromCategory(category: string): ProviderName | null {
|
||||
const match = category.match(/^provider-(\w+)$/);
|
||||
return match ? (match[1] as ProviderName) : null;
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import type { TooltipSelectItem } from "./menu.js";
|
||||
|
||||
type DisplaySettings = Settings["display"];
|
||||
type BarType = DisplaySettings["barType"];
|
||||
type BarStyle = DisplaySettings["barStyle"];
|
||||
type BarCharacter = DisplaySettings["barCharacter"];
|
||||
type BarWidth = DisplaySettings["barWidth"];
|
||||
type DividerCharacter = DisplaySettings["dividerCharacter"];
|
||||
type DividerBlanks = DisplaySettings["dividerBlanks"];
|
||||
type DisplayAlignment = DisplaySettings["alignment"];
|
||||
type OverflowMode = DisplaySettings["overflow"];
|
||||
type BaseTextColor = DisplaySettings["baseTextColor"];
|
||||
type DividerColor = DisplaySettings["dividerColor"];
|
||||
type ResetTimeFormat = DisplaySettings["resetTimeFormat"];
|
||||
type ResetTimerContainment = DisplaySettings["resetTimeContainment"];
|
||||
type StatusIndicatorMode = DisplaySettings["statusIndicatorMode"];
|
||||
type StatusIconPack = DisplaySettings["statusIconPack"];
|
||||
type ProviderLabel = DisplaySettings["providerLabel"];
|
||||
|
||||
const RANDOM_BAR_TYPES: BarType[] = ["horizontal-bar", "horizontal-single", "vertical", "braille", "shade"];
|
||||
const RANDOM_BAR_STYLES: BarStyle[] = ["bar", "percentage", "both"];
|
||||
const RANDOM_BAR_WIDTHS: BarWidth[] = [1, 4, 6, 8, 10, 12, "fill"];
|
||||
const RANDOM_BAR_CHARACTERS: BarCharacter[] = [
|
||||
"light",
|
||||
"heavy",
|
||||
"double",
|
||||
"block",
|
||||
"▮▯",
|
||||
"■□",
|
||||
"●○",
|
||||
"▲△",
|
||||
"◆◇",
|
||||
"🚀_",
|
||||
];
|
||||
const RANDOM_ALIGNMENTS: DisplayAlignment[] = ["left", "center", "right", "split"];
|
||||
const RANDOM_OVERFLOW: OverflowMode[] = ["truncate", "wrap"];
|
||||
const RANDOM_RESET_POSITIONS: DisplaySettings["resetTimePosition"][] = ["off", "front", "back", "integrated"];
|
||||
const RANDOM_RESET_FORMATS: ResetTimeFormat[] = ["relative", "datetime"];
|
||||
const RANDOM_RESET_CONTAINMENTS: ResetTimerContainment[] = ["none", "blank", "()", "[]", "<>"];
|
||||
const RANDOM_STATUS_MODES: StatusIndicatorMode[] = ["icon", "text", "icon+text"];
|
||||
const RANDOM_STATUS_PACKS: StatusIconPack[] = ["minimal", "emoji"];
|
||||
const RANDOM_PROVIDER_LABELS: ProviderLabel[] = ["plan", "subscription", "sub", "none"];
|
||||
const RANDOM_DIVIDER_CHARACTERS: DividerCharacter[] = ["none", "blank", "|", "│", "┃", "┆", "┇", "║", "•", "●", "○", "◇"];
|
||||
const RANDOM_DIVIDER_BLANKS: DividerBlanks[] = [0, 1, 2, 3];
|
||||
const RANDOM_COLOR_SCHEMES: DisplaySettings["colorScheme"][] = [
|
||||
"base-warning-error",
|
||||
"success-base-warning-error",
|
||||
"monochrome",
|
||||
];
|
||||
const RANDOM_BASE_TEXT_COLORS: BaseTextColor[] = ["dim", "muted", "text", "primary", "success", "warning", "error", "border", "borderMuted"];
|
||||
const RANDOM_BACKGROUND_COLORS: BaseTextColor[] = [
|
||||
"text",
|
||||
"selectedBg",
|
||||
"userMessageBg",
|
||||
"customMessageBg",
|
||||
"toolPendingBg",
|
||||
"toolSuccessBg",
|
||||
"toolErrorBg",
|
||||
];
|
||||
const RANDOM_DIVIDER_COLORS: DividerColor[] = [
|
||||
"primary",
|
||||
"text",
|
||||
"muted",
|
||||
"dim",
|
||||
"success",
|
||||
"warning",
|
||||
"error",
|
||||
"border",
|
||||
"borderMuted",
|
||||
"borderAccent",
|
||||
];
|
||||
const RANDOM_PADDING: number[] = [0, 1, 2, 3, 4];
|
||||
|
||||
function pickRandom<T>(items: readonly T[]): T {
|
||||
return items[Math.floor(Math.random() * items.length)] ?? items[0]!;
|
||||
}
|
||||
|
||||
function randomBool(probability = 0.5): boolean {
|
||||
return Math.random() < probability;
|
||||
}
|
||||
|
||||
const THEME_ID_LENGTH = 24;
|
||||
const THEME_ID_FALLBACK = "theme";
|
||||
|
||||
function buildThemeId(name: string): string {
|
||||
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").slice(0, THEME_ID_LENGTH) || THEME_ID_FALLBACK;
|
||||
}
|
||||
|
||||
export interface DisplayThemeTarget {
|
||||
id?: string;
|
||||
name: string;
|
||||
display: Settings["display"];
|
||||
deletable: boolean;
|
||||
}
|
||||
|
||||
export function buildDisplayThemeItems(
|
||||
settings: Settings,
|
||||
): TooltipSelectItem[] {
|
||||
const items: TooltipSelectItem[] = [];
|
||||
items.push({
|
||||
value: "user",
|
||||
label: "Restore backup",
|
||||
description: "restore your last theme",
|
||||
tooltip: "Restore your previous display theme.",
|
||||
});
|
||||
items.push({
|
||||
value: "default",
|
||||
label: "Default",
|
||||
description: "restore default settings",
|
||||
tooltip: "Reset display settings to defaults.",
|
||||
});
|
||||
items.push({
|
||||
value: "minimal",
|
||||
label: "Default Minimal",
|
||||
description: "compact display",
|
||||
tooltip: "Apply the default minimal theme.",
|
||||
});
|
||||
for (const theme of settings.displayThemes) {
|
||||
const description = theme.source === "imported" ? "manually imported theme" : "manually saved theme";
|
||||
items.push({
|
||||
value: `theme:${theme.id}`,
|
||||
label: theme.name,
|
||||
description,
|
||||
tooltip: `Manage ${theme.name}.`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function resolveDisplayThemeTarget(
|
||||
value: string,
|
||||
settings: Settings,
|
||||
defaults: Settings,
|
||||
fallbackUser: Settings["display"] | null,
|
||||
): DisplayThemeTarget | null {
|
||||
if (value === "user") {
|
||||
const display = settings.displayUserTheme ?? fallbackUser ?? settings.display;
|
||||
return { name: "Restore backup", display, deletable: false };
|
||||
}
|
||||
if (value === "default") {
|
||||
return { name: "Default", display: { ...defaults.display }, deletable: false };
|
||||
}
|
||||
if (value === "minimal") {
|
||||
return {
|
||||
name: "Default Minimal",
|
||||
display: {
|
||||
...defaults.display,
|
||||
alignment: "split",
|
||||
barStyle: "percentage",
|
||||
barType: "horizontal-bar",
|
||||
barWidth: 1,
|
||||
barCharacter: "heavy",
|
||||
containBar: true,
|
||||
brailleFillEmpty: false,
|
||||
brailleFullBlocks: false,
|
||||
colorScheme: "base-warning-error",
|
||||
usageColorTargets: {
|
||||
title: true,
|
||||
timer: true,
|
||||
bar: true,
|
||||
usageLabel: true,
|
||||
status: true,
|
||||
},
|
||||
resetTimePosition: "off",
|
||||
resetTimeFormat: "relative",
|
||||
resetTimeContainment: "blank",
|
||||
statusIndicatorMode: "icon",
|
||||
statusIconPack: "minimal",
|
||||
statusProviderDivider: false,
|
||||
statusDismissOk: true,
|
||||
showProviderName: false,
|
||||
providerLabel: "none",
|
||||
providerLabelColon: false,
|
||||
providerLabelBold: true,
|
||||
baseTextColor: "muted",
|
||||
backgroundColor: "text",
|
||||
showWindowTitle: false,
|
||||
boldWindowTitle: true,
|
||||
showUsageLabels: false,
|
||||
dividerCharacter: "none",
|
||||
dividerColor: "dim",
|
||||
dividerBlanks: 1,
|
||||
showProviderDivider: true,
|
||||
dividerFooterJoin: true,
|
||||
showTopDivider: false,
|
||||
showBottomDivider: false,
|
||||
paddingLeft: 1,
|
||||
paddingRight: 1,
|
||||
widgetPlacement: "belowEditor",
|
||||
errorThreshold: 25,
|
||||
warningThreshold: 50,
|
||||
overflow: "truncate",
|
||||
successThreshold: 75,
|
||||
},
|
||||
deletable: false,
|
||||
};
|
||||
}
|
||||
if (value.startsWith("theme:")) {
|
||||
const id = value.replace("theme:", "");
|
||||
const theme = settings.displayThemes.find((entry) => entry.id === id);
|
||||
if (!theme) return null;
|
||||
return { id: theme.id, name: theme.name, display: theme.display, deletable: true };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildRandomDisplay(base: DisplaySettings): DisplaySettings {
|
||||
const display: DisplaySettings = { ...base };
|
||||
|
||||
display.alignment = pickRandom(RANDOM_ALIGNMENTS);
|
||||
display.overflow = pickRandom(RANDOM_OVERFLOW);
|
||||
const padding = pickRandom(RANDOM_PADDING);
|
||||
display.paddingLeft = padding;
|
||||
display.paddingRight = padding;
|
||||
display.barStyle = pickRandom(RANDOM_BAR_STYLES);
|
||||
display.barType = pickRandom(RANDOM_BAR_TYPES);
|
||||
display.barWidth = pickRandom(RANDOM_BAR_WIDTHS);
|
||||
display.barCharacter = pickRandom(RANDOM_BAR_CHARACTERS);
|
||||
display.containBar = randomBool();
|
||||
display.brailleFillEmpty = randomBool();
|
||||
display.brailleFullBlocks = randomBool();
|
||||
display.colorScheme = pickRandom(RANDOM_COLOR_SCHEMES);
|
||||
|
||||
const usageColorTargets = {
|
||||
title: randomBool(),
|
||||
timer: randomBool(),
|
||||
bar: randomBool(),
|
||||
usageLabel: randomBool(),
|
||||
status: randomBool(),
|
||||
};
|
||||
if (!usageColorTargets.title && !usageColorTargets.timer && !usageColorTargets.bar && !usageColorTargets.usageLabel && !usageColorTargets.status) {
|
||||
usageColorTargets.bar = true;
|
||||
}
|
||||
display.usageColorTargets = usageColorTargets;
|
||||
display.resetTimePosition = pickRandom(RANDOM_RESET_POSITIONS);
|
||||
display.resetTimeFormat = pickRandom(RANDOM_RESET_FORMATS);
|
||||
display.resetTimeContainment = pickRandom(RANDOM_RESET_CONTAINMENTS);
|
||||
display.statusIndicatorMode = pickRandom(RANDOM_STATUS_MODES);
|
||||
display.statusIconPack = pickRandom(RANDOM_STATUS_PACKS);
|
||||
display.statusProviderDivider = randomBool();
|
||||
display.statusDismissOk = randomBool();
|
||||
display.showProviderName = randomBool();
|
||||
display.providerLabel = pickRandom(RANDOM_PROVIDER_LABELS);
|
||||
display.providerLabelColon = display.providerLabel !== "none" && randomBool();
|
||||
display.providerLabelBold = randomBool();
|
||||
display.baseTextColor = pickRandom(RANDOM_BASE_TEXT_COLORS);
|
||||
display.backgroundColor = pickRandom(RANDOM_BACKGROUND_COLORS);
|
||||
display.boldWindowTitle = randomBool();
|
||||
display.showUsageLabels = randomBool();
|
||||
display.dividerCharacter = pickRandom(RANDOM_DIVIDER_CHARACTERS);
|
||||
display.dividerColor = pickRandom(RANDOM_DIVIDER_COLORS);
|
||||
display.dividerBlanks = pickRandom(RANDOM_DIVIDER_BLANKS);
|
||||
display.showProviderDivider = randomBool();
|
||||
display.dividerFooterJoin = randomBool();
|
||||
display.showTopDivider = randomBool();
|
||||
display.showBottomDivider = randomBool();
|
||||
|
||||
if (display.dividerCharacter === "none") {
|
||||
display.showProviderDivider = false;
|
||||
display.dividerFooterJoin = false;
|
||||
display.showTopDivider = false;
|
||||
display.showBottomDivider = false;
|
||||
}
|
||||
if (display.providerLabel === "none") {
|
||||
display.providerLabelColon = false;
|
||||
}
|
||||
|
||||
return display;
|
||||
}
|
||||
|
||||
export function buildThemeActionItems(target: DisplayThemeTarget): TooltipSelectItem[] {
|
||||
const items: TooltipSelectItem[] = [
|
||||
{
|
||||
value: "load",
|
||||
label: "Load",
|
||||
description: "apply this theme",
|
||||
tooltip: "Apply the selected theme.",
|
||||
},
|
||||
{
|
||||
value: "share",
|
||||
label: "Share",
|
||||
description: "post share string",
|
||||
tooltip: "Post a shareable theme string to chat.",
|
||||
},
|
||||
];
|
||||
if (target.deletable) {
|
||||
items.push({
|
||||
value: "rename",
|
||||
label: "Rename",
|
||||
description: "rename saved theme",
|
||||
tooltip: "Rename this saved theme.",
|
||||
});
|
||||
items.push({
|
||||
value: "delete",
|
||||
label: "Delete",
|
||||
description: "remove saved theme",
|
||||
tooltip: "Remove this theme from saved themes.",
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export function upsertDisplayTheme(
|
||||
settings: Settings,
|
||||
name: string,
|
||||
display: Settings["display"],
|
||||
source?: "saved" | "imported",
|
||||
): Settings {
|
||||
const trimmed = name.trim() || "Theme";
|
||||
const id = buildThemeId(trimmed);
|
||||
const snapshot = { ...display };
|
||||
const existing = settings.displayThemes.find((theme) => theme.id === id);
|
||||
const resolvedSource = source ?? existing?.source ?? "saved";
|
||||
if (existing) {
|
||||
existing.name = trimmed;
|
||||
existing.display = snapshot;
|
||||
existing.source = resolvedSource;
|
||||
} else {
|
||||
settings.displayThemes.push({ id, name: trimmed, display: snapshot, source: resolvedSource });
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function renameDisplayTheme(settings: Settings, id: string, name: string): Settings {
|
||||
const trimmed = name.trim() || "Theme";
|
||||
const nextId = buildThemeId(trimmed);
|
||||
const existing = settings.displayThemes.find((theme) => theme.id === id);
|
||||
if (!existing) return settings;
|
||||
if (nextId === id) {
|
||||
existing.name = trimmed;
|
||||
return settings;
|
||||
}
|
||||
const collision = settings.displayThemes.find((theme) => theme.id === nextId);
|
||||
if (collision) {
|
||||
collision.name = trimmed;
|
||||
collision.display = existing.display;
|
||||
collision.source = existing.source;
|
||||
settings.displayThemes = settings.displayThemes.filter((theme) => theme.id !== id);
|
||||
return settings;
|
||||
}
|
||||
existing.id = nextId;
|
||||
existing.name = trimmed;
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function saveDisplayTheme(settings: Settings, name: string): Settings {
|
||||
return upsertDisplayTheme(settings, name, settings.display, "saved");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Display theme share helpers.
|
||||
*/
|
||||
|
||||
import type { Settings } from "./settings-types.js";
|
||||
import { mergeSettings } from "./settings-types.js";
|
||||
|
||||
const SHARE_SEPARATOR = ":";
|
||||
const DISPLAY_SHARE_VERSION = 1;
|
||||
|
||||
export interface DisplaySharePayload {
|
||||
v: number;
|
||||
display: Settings["display"];
|
||||
}
|
||||
|
||||
export interface DecodedDisplayShare {
|
||||
name: string;
|
||||
display: Settings["display"];
|
||||
version: number;
|
||||
isNewerVersion: boolean;
|
||||
hasName: boolean;
|
||||
}
|
||||
|
||||
function encodeDisplaySharePayload(display: Settings["display"]): string {
|
||||
const payload: DisplaySharePayload = { v: DISPLAY_SHARE_VERSION, display };
|
||||
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
}
|
||||
|
||||
export function buildDisplayShareString(name: string, display: Settings["display"]): string {
|
||||
const encoded = encodeDisplaySharePayload(display);
|
||||
const trimmedName = name.trim() || "custom";
|
||||
return `${trimmedName}${SHARE_SEPARATOR}${encoded}`;
|
||||
}
|
||||
|
||||
export function buildDisplayShareStringWithoutName(display: Settings["display"]): string {
|
||||
return encodeDisplaySharePayload(display);
|
||||
}
|
||||
|
||||
export function decodeDisplayShareString(input: string): DecodedDisplayShare | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
let name = "custom";
|
||||
let hasName = false;
|
||||
let payload = trimmed;
|
||||
const separatorIndex = trimmed.indexOf(SHARE_SEPARATOR);
|
||||
if (separatorIndex >= 0) {
|
||||
const candidateName = trimmed.slice(0, separatorIndex).trim();
|
||||
payload = trimmed.slice(separatorIndex + 1).trim();
|
||||
if (candidateName) {
|
||||
name = candidateName;
|
||||
hasName = true;
|
||||
}
|
||||
}
|
||||
if (!payload) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(payload, "base64url").toString("utf-8");
|
||||
const parsed = JSON.parse(decoded) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
const displayCandidate = (parsed as DisplaySharePayload).display ?? parsed;
|
||||
if (!displayCandidate || typeof displayCandidate !== "object" || Array.isArray(displayCandidate)) {
|
||||
return null;
|
||||
}
|
||||
const merged = mergeSettings({ display: displayCandidate } as Partial<Settings>).display;
|
||||
const version = typeof (parsed as DisplaySharePayload).v === "number" ? (parsed as DisplaySharePayload).v : 0;
|
||||
return {
|
||||
name,
|
||||
display: merged,
|
||||
version,
|
||||
isNewerVersion: version > DISPLAY_SHARE_VERSION,
|
||||
hasName,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* Shared types and metadata for sub-* extensions.
|
||||
*/
|
||||
|
||||
export const PROVIDERS = ["anthropic", "copilot", "gemini", "antigravity", "codex", "kiro", "zai", "opencode-go"] as const;
|
||||
|
||||
export type ProviderName = (typeof PROVIDERS)[number];
|
||||
|
||||
export type StatusIndicator = "none" | "minor" | "major" | "critical" | "maintenance" | "unknown";
|
||||
|
||||
export interface ProviderStatus {
|
||||
indicator: StatusIndicator;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RateWindow {
|
||||
label: string;
|
||||
usedPercent: number;
|
||||
resetDescription?: string;
|
||||
resetAt?: string;
|
||||
}
|
||||
|
||||
export interface UsageSnapshot {
|
||||
provider: ProviderName;
|
||||
displayName: string;
|
||||
windows: RateWindow[];
|
||||
extraUsageEnabled?: boolean;
|
||||
fiveHourUsage?: number;
|
||||
lastSuccessAt?: number;
|
||||
error?: UsageError;
|
||||
status?: ProviderStatus;
|
||||
requestsSummary?: string;
|
||||
requestsRemaining?: number;
|
||||
requestsEntitlement?: number;
|
||||
}
|
||||
|
||||
export type UsageErrorCode =
|
||||
| "NO_CREDENTIALS"
|
||||
| "NO_CLI"
|
||||
| "NOT_LOGGED_IN"
|
||||
| "FETCH_FAILED"
|
||||
| "HTTP_ERROR"
|
||||
| "API_ERROR"
|
||||
| "TIMEOUT"
|
||||
| "UNKNOWN";
|
||||
|
||||
export interface UsageError {
|
||||
code: UsageErrorCode;
|
||||
message: string;
|
||||
httpStatus?: number;
|
||||
}
|
||||
|
||||
export interface ProviderUsageEntry {
|
||||
provider: ProviderName;
|
||||
usage?: UsageSnapshot;
|
||||
}
|
||||
|
||||
export type ProviderEnabledSetting = "auto" | "on" | "off" | boolean;
|
||||
|
||||
export interface CoreProviderSettings {
|
||||
enabled: ProviderEnabledSetting;
|
||||
displayName?: string;
|
||||
fetchStatus: boolean;
|
||||
extraUsageCurrencySymbol?: string;
|
||||
extraUsageDecimalSeparator?: "." | ",";
|
||||
}
|
||||
|
||||
export interface CoreProviderSettingsMap {
|
||||
anthropic: CoreProviderSettings;
|
||||
copilot: CoreProviderSettings;
|
||||
gemini: CoreProviderSettings;
|
||||
antigravity: CoreProviderSettings;
|
||||
codex: CoreProviderSettings;
|
||||
kiro: CoreProviderSettings;
|
||||
zai: CoreProviderSettings;
|
||||
"opencode-go": CoreProviderSettings;
|
||||
}
|
||||
|
||||
export interface BehaviorSettings {
|
||||
refreshInterval: number;
|
||||
minRefreshInterval: number;
|
||||
refreshOnTurnStart: boolean;
|
||||
refreshOnToolResult: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_BEHAVIOR_SETTINGS: BehaviorSettings = {
|
||||
refreshInterval: 60,
|
||||
minRefreshInterval: 10,
|
||||
refreshOnTurnStart: false,
|
||||
refreshOnToolResult: false,
|
||||
};
|
||||
|
||||
export function getDefaultCoreProviderSettings(): CoreProviderSettingsMap {
|
||||
const defaults = {} as CoreProviderSettingsMap;
|
||||
for (const provider of PROVIDERS) {
|
||||
defaults[provider] = {
|
||||
enabled: "auto" as ProviderEnabledSetting,
|
||||
fetchStatus: Boolean(PROVIDER_METADATA[provider]?.status),
|
||||
};
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
|
||||
export function getDefaultCoreSettings(): CoreSettings {
|
||||
return {
|
||||
providers: getDefaultCoreProviderSettings(),
|
||||
behavior: { ...DEFAULT_BEHAVIOR_SETTINGS },
|
||||
statusRefresh: { ...DEFAULT_BEHAVIOR_SETTINGS },
|
||||
providerOrder: [...PROVIDERS],
|
||||
defaultProvider: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CoreSettings {
|
||||
providers: CoreProviderSettingsMap;
|
||||
behavior: BehaviorSettings;
|
||||
statusRefresh: BehaviorSettings;
|
||||
providerOrder: ProviderName[];
|
||||
defaultProvider: ProviderName | null;
|
||||
}
|
||||
|
||||
export type SubCoreState = {
|
||||
provider?: ProviderName;
|
||||
usage?: UsageSnapshot;
|
||||
};
|
||||
|
||||
export type SubCoreAllState = {
|
||||
provider?: ProviderName;
|
||||
entries: ProviderUsageEntry[];
|
||||
};
|
||||
|
||||
export type SubCoreEvents =
|
||||
| { type: "sub-core:ready"; state: SubCoreState }
|
||||
| { type: "sub-core:update-current"; state: SubCoreState }
|
||||
| { type: "sub-core:update-all"; state: SubCoreAllState };
|
||||
|
||||
export interface StatusPageComponentMatch {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type ProviderStatusConfig =
|
||||
| { type: "statuspage"; url: string; component?: StatusPageComponentMatch }
|
||||
| { type: "google-workspace" };
|
||||
|
||||
export interface ProviderDetectionConfig {
|
||||
providerTokens: string[];
|
||||
modelTokens: string[];
|
||||
}
|
||||
|
||||
export interface ProviderMetadata {
|
||||
displayName: string;
|
||||
detection?: ProviderDetectionConfig;
|
||||
status?: ProviderStatusConfig;
|
||||
}
|
||||
|
||||
export const PROVIDER_METADATA: Record<ProviderName, ProviderMetadata> = {
|
||||
anthropic: {
|
||||
displayName: "Anthropic (Claude)",
|
||||
status: { type: "statuspage", url: "https://status.anthropic.com/api/v2/status.json" },
|
||||
detection: { providerTokens: ["anthropic"], modelTokens: ["claude"] },
|
||||
},
|
||||
copilot: {
|
||||
displayName: "GitHub Copilot",
|
||||
status: { type: "statuspage", url: "https://www.githubstatus.com/api/v2/status.json" },
|
||||
detection: { providerTokens: ["copilot", "github"], modelTokens: [] },
|
||||
},
|
||||
gemini: {
|
||||
displayName: "Google Gemini",
|
||||
status: { type: "google-workspace" },
|
||||
detection: { providerTokens: ["google", "gemini"], modelTokens: ["gemini"] },
|
||||
},
|
||||
antigravity: {
|
||||
displayName: "Antigravity",
|
||||
status: { type: "google-workspace" },
|
||||
detection: { providerTokens: ["antigravity"], modelTokens: ["antigravity"] },
|
||||
},
|
||||
codex: {
|
||||
displayName: "OpenAI Codex",
|
||||
status: {
|
||||
type: "statuspage",
|
||||
url: "https://status.openai.com/api/v2/status.json",
|
||||
component: {
|
||||
id: "01JVCV8YSWZFRSM1G5CVP253SK",
|
||||
name: "Codex",
|
||||
},
|
||||
},
|
||||
detection: { providerTokens: ["openai", "codex"], modelTokens: ["gpt", "o1", "o3"] },
|
||||
},
|
||||
kiro: {
|
||||
displayName: "AWS Kiro",
|
||||
detection: { providerTokens: ["kiro", "aws"], modelTokens: [] },
|
||||
},
|
||||
zai: {
|
||||
displayName: "z.ai",
|
||||
detection: { providerTokens: ["zai", "z.ai", "xai"], modelTokens: [] },
|
||||
},
|
||||
"opencode-go": {
|
||||
displayName: "OpenCode (MiniMax)",
|
||||
detection: { providerTokens: ["opencode-go", "opencode", "minimax"], modelTokens: ["kimi", "minimax", "m2.5"] },
|
||||
},
|
||||
};
|
||||
|
||||
export const PROVIDER_DISPLAY_NAMES = Object.fromEntries(
|
||||
PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
|
||||
) as Record<ProviderName, string>;
|
||||
|
||||
export const MODEL_MULTIPLIERS: Record<string, number> = {
|
||||
"Claude Haiku 4.5": 0.33,
|
||||
"Claude Opus 4.1": 10,
|
||||
"Claude Opus 4.5": 3,
|
||||
"Claude Sonnet 4": 1,
|
||||
"Claude Sonnet 4.5": 1,
|
||||
"Gemini 2.5 Pro": 1,
|
||||
"Gemini 3 Flash": 0.33,
|
||||
"Gemini 3 Pro": 1,
|
||||
"GPT-4.1": 0,
|
||||
"GPT-4o": 0,
|
||||
"GPT-5": 1,
|
||||
"GPT-5 mini": 0,
|
||||
"GPT-5-Codex": 1,
|
||||
"GPT-5.1": 1,
|
||||
"GPT-5.1-Codex": 1,
|
||||
"GPT-5.1-Codex-Mini": 0.33,
|
||||
"GPT-5.1-Codex-Max": 1,
|
||||
"GPT-5.2": 1,
|
||||
"Grok Code Fast 1": 0.25,
|
||||
"Raptor mini": 0,
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* Status indicator helpers.
|
||||
*/
|
||||
|
||||
import type { ProviderStatus } from "./types.js";
|
||||
import type { StatusIconPack } from "./settings-types.js";
|
||||
|
||||
const STATUS_ICON_PACKS: Record<Exclude<StatusIconPack, "custom">, Record<ProviderStatus["indicator"], string>> = {
|
||||
minimal: {
|
||||
none: "✓",
|
||||
minor: "⚠",
|
||||
major: "⚠",
|
||||
critical: "×",
|
||||
maintenance: "~",
|
||||
unknown: "?",
|
||||
},
|
||||
emoji: {
|
||||
none: "✅",
|
||||
minor: "⚠️",
|
||||
major: "🟠",
|
||||
critical: "🔴",
|
||||
maintenance: "🔧",
|
||||
unknown: "❓",
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_CUSTOM_ICONS = ["✓", "⚠", "×", "?"];
|
||||
const CUSTOM_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
function parseCustomIcons(value?: string): [string, string, string, string] {
|
||||
if (!value) return DEFAULT_CUSTOM_ICONS as [string, string, string, string];
|
||||
const segments = Array.from(CUSTOM_SEGMENTER.segment(value), (entry) => entry.segment)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
if (segments.length < 3) return DEFAULT_CUSTOM_ICONS as [string, string, string, string];
|
||||
if (segments.length === 3) {
|
||||
return [segments[0], segments[1], segments[2], DEFAULT_CUSTOM_ICONS[3]] as [string, string, string, string];
|
||||
}
|
||||
return [segments[0], segments[1], segments[2], segments[3]] as [string, string, string, string];
|
||||
}
|
||||
|
||||
function buildCustomPack(custom?: string): Record<ProviderStatus["indicator"], string> {
|
||||
const [ok, warn, error, unknown] = parseCustomIcons(custom);
|
||||
return {
|
||||
none: ok,
|
||||
minor: warn,
|
||||
major: error,
|
||||
critical: error,
|
||||
maintenance: warn,
|
||||
unknown,
|
||||
};
|
||||
}
|
||||
|
||||
export function getStatusIcon(
|
||||
status: ProviderStatus | undefined,
|
||||
pack: StatusIconPack,
|
||||
custom?: string,
|
||||
): string {
|
||||
if (!status) return "";
|
||||
if (pack === "custom") {
|
||||
return buildCustomPack(custom)[status.indicator] ?? "";
|
||||
}
|
||||
return STATUS_ICON_PACKS[pack][status.indicator] ?? "";
|
||||
}
|
||||
|
||||
export function getStatusLabel(
|
||||
status: ProviderStatus | undefined,
|
||||
useAbbreviated = false,
|
||||
): string {
|
||||
if (!status) return "";
|
||||
if (useAbbreviated) {
|
||||
switch (status.indicator) {
|
||||
case "none":
|
||||
return "Status OK";
|
||||
case "minor":
|
||||
return "Status Degr.";
|
||||
case "major":
|
||||
case "critical":
|
||||
return "Status Crit.";
|
||||
case "maintenance":
|
||||
return "Status Maint.";
|
||||
case "unknown":
|
||||
default:
|
||||
return "Status Unk.";
|
||||
}
|
||||
}
|
||||
if (status.description) return status.description;
|
||||
switch (status.indicator) {
|
||||
case "none":
|
||||
return "Operational";
|
||||
case "minor":
|
||||
return "Degraded";
|
||||
case "major":
|
||||
return "Outage";
|
||||
case "critical":
|
||||
return "Outage";
|
||||
case "maintenance":
|
||||
return "Maintenance";
|
||||
case "unknown":
|
||||
default:
|
||||
return "Status Unknown";
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Storage abstraction for settings persistence.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface StorageAdapter {
|
||||
readFile(path: string): string | undefined;
|
||||
writeFile(path: string, contents: string): void;
|
||||
writeFileExclusive(path: string, contents: string): boolean;
|
||||
exists(path: string): boolean;
|
||||
removeFile(path: string): void;
|
||||
ensureDir(path: string): void;
|
||||
}
|
||||
|
||||
export function createFsStorage(): StorageAdapter {
|
||||
return {
|
||||
readFile(filePath: string): string | undefined {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
writeFile(filePath: string, contents: string): void {
|
||||
fs.writeFileSync(filePath, contents, "utf-8");
|
||||
},
|
||||
writeFileExclusive(filePath: string, contents: string): boolean {
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
exists(filePath: string): boolean {
|
||||
return fs.existsSync(filePath);
|
||||
},
|
||||
removeFile(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// Ignore remove errors
|
||||
}
|
||||
},
|
||||
ensureDir(dirPath: string): void {
|
||||
fs.mkdirSync(path.resolve(dirPath), { recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let activeStorage: StorageAdapter = createFsStorage();
|
||||
|
||||
export function getStorage(): StorageAdapter {
|
||||
return activeStorage;
|
||||
}
|
||||
|
||||
export function setStorage(storage: StorageAdapter): void {
|
||||
activeStorage = storage;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Core types for the sub-bar extension
|
||||
*/
|
||||
|
||||
export type {
|
||||
ProviderName,
|
||||
StatusIndicator,
|
||||
ProviderStatus,
|
||||
RateWindow,
|
||||
UsageSnapshot,
|
||||
UsageError,
|
||||
UsageErrorCode,
|
||||
ProviderUsageEntry,
|
||||
SubCoreState,
|
||||
SubCoreAllState,
|
||||
SubCoreEvents,
|
||||
} from "./shared.js";
|
||||
|
||||
export { PROVIDERS } from "./shared.js";
|
||||
|
||||
export type ModelInfo = {
|
||||
provider?: string;
|
||||
id?: string;
|
||||
scopedModelPatterns?: string[];
|
||||
};
|
||||
@@ -1,304 +0,0 @@
|
||||
import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
Input,
|
||||
fuzzyFilter,
|
||||
getEditorKeybindings,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
export interface SettingsListOptions {
|
||||
enableSearch?: boolean;
|
||||
}
|
||||
|
||||
export const CUSTOM_OPTION = "__custom__";
|
||||
export const CUSTOM_LABEL = "custom";
|
||||
|
||||
export type { SettingItem, SettingsListTheme };
|
||||
|
||||
export class SettingsList implements Component {
|
||||
private items: SettingItem[];
|
||||
private filteredItems: SettingItem[];
|
||||
private theme: SettingsListTheme;
|
||||
private selectedIndex = 0;
|
||||
private maxVisible: number;
|
||||
private onChange: (id: string, newValue: string) => void;
|
||||
private onCancel: () => void;
|
||||
private searchInput?: Input;
|
||||
private searchEnabled: boolean;
|
||||
private submenuComponent: Component | null = null;
|
||||
private submenuItemIndex: number | null = null;
|
||||
|
||||
constructor(
|
||||
items: SettingItem[],
|
||||
maxVisible: number,
|
||||
theme: SettingsListTheme,
|
||||
onChange: (id: string, newValue: string) => void,
|
||||
onCancel: () => void,
|
||||
options: SettingsListOptions = {},
|
||||
) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
this.onChange = onChange;
|
||||
this.onCancel = onCancel;
|
||||
this.searchEnabled = options.enableSearch ?? false;
|
||||
|
||||
if (this.searchEnabled) {
|
||||
this.searchInput = new Input();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update an item's currentValue */
|
||||
updateValue(id: string, newValue: string): void {
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.currentValue = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
getSelectedId(): string | null {
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
const item = displayItems[this.selectedIndex];
|
||||
return item?.id ?? null;
|
||||
}
|
||||
|
||||
setSelectedId(id: string): void {
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
const index = displayItems.findIndex((item) => item.id === id);
|
||||
if (index >= 0) {
|
||||
this.selectedIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.submenuComponent?.invalidate?.();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// If submenu is active, render it instead
|
||||
if (this.submenuComponent) {
|
||||
return this.submenuComponent.render(width);
|
||||
}
|
||||
return this.renderMainList(width);
|
||||
}
|
||||
|
||||
private renderMainList(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
if (this.searchEnabled && this.searchInput) {
|
||||
lines.push(...this.searchInput.render(width));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (this.items.length === 0) {
|
||||
lines.push(this.theme.hint(" No settings available"));
|
||||
if (this.searchEnabled) {
|
||||
this.addHintLine(lines);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
if (displayItems.length === 0) {
|
||||
lines.push(this.theme.hint(" No matching settings"));
|
||||
this.addHintLine(lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
||||
displayItems.length - this.maxVisible,
|
||||
),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
|
||||
|
||||
// Calculate max label width for alignment
|
||||
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = displayItems[i];
|
||||
if (!item) continue;
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const prefix = isSelected ? this.theme.cursor : " ";
|
||||
const prefixWidth = visibleWidth(prefix);
|
||||
|
||||
// Pad label to align values
|
||||
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
||||
const labelText = this.theme.label(labelPadded, isSelected);
|
||||
|
||||
// Calculate space for value
|
||||
const separator = " ";
|
||||
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
||||
const valueMaxWidth = Math.max(1, width - usedWidth - 2);
|
||||
const optionLines = isSelected && item.values && item.values.length > 0
|
||||
? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth)
|
||||
: null;
|
||||
const valueText = optionLines
|
||||
? optionLines[0] ?? ""
|
||||
: this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
|
||||
const line = prefix + labelText + separator + valueText;
|
||||
lines.push(truncateToWidth(line, width, ""));
|
||||
if (optionLines && optionLines.length > 1) {
|
||||
const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator));
|
||||
for (const continuation of optionLines.slice(1)) {
|
||||
lines.push(truncateToWidth(indent + continuation, width, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < displayItems.length) {
|
||||
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
|
||||
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
|
||||
}
|
||||
|
||||
// Add description for selected item
|
||||
const selectedItem = displayItems[this.selectedIndex];
|
||||
if (selectedItem?.description) {
|
||||
lines.push("");
|
||||
const wrapWidth = Math.max(1, width - 4);
|
||||
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth);
|
||||
for (const line of wrappedDesc) {
|
||||
const prefixed = ` ${line}`;
|
||||
lines.push(this.theme.description(truncateToWidth(prefixed, width, "")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add hint
|
||||
this.addHintLine(lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// If submenu is active, delegate all input to it
|
||||
// The submenu's onCancel (triggered by escape) will call done() which closes it
|
||||
if (this.submenuComponent) {
|
||||
this.submenuComponent.handleInput?.(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const kb = getEditorKeybindings();
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
|
||||
if (kb.matches(data, "selectUp")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
|
||||
} else if (kb.matches(data, "selectDown")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
} else if (kb.matches(data, "cursorLeft")) {
|
||||
this.stepValue(-1);
|
||||
} else if (kb.matches(data, "cursorRight")) {
|
||||
this.stepValue(1);
|
||||
} else if (kb.matches(data, "selectConfirm") || data === " ") {
|
||||
this.activateItem();
|
||||
} else if (kb.matches(data, "selectCancel")) {
|
||||
this.onCancel();
|
||||
} else if (this.searchEnabled && this.searchInput) {
|
||||
const sanitized = data.replace(/ /g, "");
|
||||
if (!sanitized) {
|
||||
return;
|
||||
}
|
||||
this.searchInput.handleInput(sanitized);
|
||||
this.applyFilter(this.searchInput.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private stepValue(direction: -1 | 1): void {
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
const item = displayItems[this.selectedIndex];
|
||||
if (!item || !item.values || item.values.length === 0) return;
|
||||
const values = item.values;
|
||||
let currentIndex = values.indexOf(item.currentValue);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = direction > 0 ? 0 : values.length - 1;
|
||||
}
|
||||
const nextIndex = (currentIndex + direction + values.length) % values.length;
|
||||
const newValue = values[nextIndex];
|
||||
if (newValue === CUSTOM_OPTION) {
|
||||
item.currentValue = newValue;
|
||||
this.onChange(item.id, newValue);
|
||||
return;
|
||||
}
|
||||
item.currentValue = newValue;
|
||||
this.onChange(item.id, newValue);
|
||||
}
|
||||
|
||||
private activateItem(): void {
|
||||
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
|
||||
if (!item) return;
|
||||
|
||||
const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION));
|
||||
const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue);
|
||||
|
||||
if (item.submenu && hasCustom) {
|
||||
if (currentIsCustom || item.currentValue === CUSTOM_OPTION) {
|
||||
this.openSubmenu(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
this.openSubmenu(item);
|
||||
}
|
||||
}
|
||||
|
||||
private closeSubmenu(): void {
|
||||
this.submenuComponent = null;
|
||||
// Restore selection to the item that opened the submenu
|
||||
if (this.submenuItemIndex !== null) {
|
||||
this.selectedIndex = this.submenuItemIndex;
|
||||
this.submenuItemIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
private applyFilter(query: string): void {
|
||||
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
private formatOptionsInline(item: SettingItem, values: string[]): string {
|
||||
const separator = this.theme.description(" • ");
|
||||
const hasCustom = values.includes(CUSTOM_OPTION);
|
||||
const currentIsCustom = hasCustom && !values.includes(item.currentValue);
|
||||
return values
|
||||
.map((value) => {
|
||||
const label = value === CUSTOM_OPTION
|
||||
? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL)
|
||||
: value;
|
||||
const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION);
|
||||
return this.theme.value(label, selected);
|
||||
})
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
private openSubmenu(item: SettingItem): void {
|
||||
if (!item.submenu) return;
|
||||
this.submenuItemIndex = this.selectedIndex;
|
||||
this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => {
|
||||
if (selectedValue !== undefined) {
|
||||
item.currentValue = selectedValue;
|
||||
this.onChange(item.id, selectedValue);
|
||||
}
|
||||
this.closeSubmenu();
|
||||
});
|
||||
}
|
||||
|
||||
private addHintLine(lines: string[]): void {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
this.theme.hint(
|
||||
this.searchEnabled
|
||||
? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel"
|
||||
: " ←/→ change · Enter/Space edit custom · Esc to cancel",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Usage data types shared across modules.
|
||||
*/
|
||||
|
||||
export type { ProviderUsageEntry } from "../../shared.js";
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Utility functions for the sub-bar display layer.
|
||||
*/
|
||||
|
||||
import { MODEL_MULTIPLIERS } from "./shared.js";
|
||||
|
||||
export function normalizeTokens(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({
|
||||
label,
|
||||
multiplier,
|
||||
tokens: normalizeTokens(label),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Get the request multiplier for a model ID
|
||||
* Uses fuzzy matching against known model names
|
||||
*/
|
||||
export function getModelMultiplier(modelId: string | undefined): number | undefined {
|
||||
if (!modelId) return undefined;
|
||||
const modelTokens = normalizeTokens(modelId);
|
||||
if (modelTokens.length === 0) return undefined;
|
||||
|
||||
let bestMatch: { multiplier: number; tokenCount: number } | undefined;
|
||||
for (const entry of MODEL_MULTIPLIER_TOKENS) {
|
||||
const isMatch = entry.tokens.every((token) => modelTokens.includes(token));
|
||||
if (!isMatch) continue;
|
||||
const tokenCount = entry.tokens.length;
|
||||
if (!bestMatch || tokenCount > bestMatch.tokenCount) {
|
||||
bestMatch = { multiplier: entry.multiplier, tokenCount };
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.multiplier;
|
||||
}
|
||||
@@ -1,535 +0,0 @@
|
||||
/**
|
||||
* sub-core - Shared usage data core for sub-* extensions.
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import * as fs from "node:fs";
|
||||
import type { Dependencies, ProviderName, SubCoreState, UsageSnapshot } from "./src/types.js";
|
||||
import { getDefaultSettings, type Settings } from "./src/settings-types.js";
|
||||
import type { ProviderUsageEntry } from "./src/usage/types.js";
|
||||
import { createDefaultDependencies } from "./src/dependencies.js";
|
||||
import { createUsageController, type UsageUpdate } from "./src/usage/controller.js";
|
||||
import { fetchUsageEntries, getCachedUsageEntries } from "./src/usage/fetch.js";
|
||||
import { onCacheSnapshot, onCacheUpdate, watchCacheUpdates, type Cache } from "./src/cache.js";
|
||||
import { isExpectedMissingData } from "./src/errors.js";
|
||||
import { getStorage } from "./src/storage.js";
|
||||
import { clearSettingsCache, loadSettings, saveSettings, SETTINGS_PATH } from "./src/settings.js";
|
||||
import { showSettingsUI } from "./src/settings-ui.js";
|
||||
|
||||
type SubCoreRequest =
|
||||
| {
|
||||
type?: "current";
|
||||
includeSettings?: boolean;
|
||||
reply: (payload: { state: SubCoreState; settings?: Settings }) => void;
|
||||
}
|
||||
| {
|
||||
type: "entries";
|
||||
force?: boolean;
|
||||
reply: (payload: { entries: ProviderUsageEntry[] }) => void;
|
||||
};
|
||||
|
||||
type SubCoreAction = {
|
||||
type: "refresh" | "cycleProvider";
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
const TOOL_NAMES = {
|
||||
usage: ["sub_get_usage", "get_current_usage"],
|
||||
allUsage: ["sub_get_all_usage", "get_all_usage"],
|
||||
} as const;
|
||||
|
||||
type ToolName = (typeof TOOL_NAMES)[keyof typeof TOOL_NAMES][number];
|
||||
|
||||
type SubCoreGlobalState = { active: boolean };
|
||||
const subCoreGlobal = globalThis as typeof globalThis & { __piSubCore?: SubCoreGlobalState };
|
||||
|
||||
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
|
||||
const result = { ...target } as T;
|
||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
if (
|
||||
sourceValue !== undefined &&
|
||||
typeof sourceValue === "object" &&
|
||||
sourceValue !== null &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
typeof targetValue === "object" &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T];
|
||||
} else if (sourceValue !== undefined) {
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stripUsageProvider(usage?: UsageSnapshot): Omit<UsageSnapshot, "provider"> | undefined {
|
||||
if (!usage) return undefined;
|
||||
const { provider: _provider, ...rest } = usage;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the extension
|
||||
*/
|
||||
export default function createExtension(pi: ExtensionAPI, deps: Dependencies = createDefaultDependencies()): void {
|
||||
if (subCoreGlobal.__piSubCore?.active) {
|
||||
return;
|
||||
}
|
||||
subCoreGlobal.__piSubCore = { active: true };
|
||||
|
||||
let usageRefreshInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let statusRefreshInterval: ReturnType<typeof setInterval> | undefined;
|
||||
let lastContext: ExtensionContext | undefined;
|
||||
let lastUsageRefreshAt = 0;
|
||||
let lastStatusRefreshAt = 0;
|
||||
let settings: Settings = getDefaultSettings();
|
||||
let settingsLoaded = false;
|
||||
let toolsRegistered = false;
|
||||
let lastState: SubCoreState = {};
|
||||
let settingsSnapshot = "";
|
||||
let settingsMtimeMs = 0;
|
||||
let settingsDebounce: NodeJS.Timeout | undefined;
|
||||
let settingsWatcher: fs.FSWatcher | undefined;
|
||||
let settingsPoll: NodeJS.Timeout | undefined;
|
||||
let settingsWatchStarted = false;
|
||||
|
||||
const controller = createUsageController(deps);
|
||||
const controllerState = {
|
||||
currentProvider: undefined as ProviderName | undefined,
|
||||
cachedUsage: undefined as UsageSnapshot | undefined,
|
||||
providerCycleIndex: 0,
|
||||
};
|
||||
|
||||
let lastAllSnapshot = "";
|
||||
let lastCurrentSnapshot = "";
|
||||
|
||||
const emitCurrentUpdate = (provider?: ProviderName, usage?: UsageSnapshot): void => {
|
||||
lastState = { provider, usage };
|
||||
const payload = JSON.stringify(lastState);
|
||||
if (payload === lastCurrentSnapshot) return;
|
||||
lastCurrentSnapshot = payload;
|
||||
pi.events.emit("sub-core:update-current", { state: lastState });
|
||||
};
|
||||
|
||||
const unsubscribeCacheSnapshot = onCacheSnapshot((cache: Cache) => {
|
||||
const ttlMs = settings.behavior.refreshInterval * 1000;
|
||||
const now = Date.now();
|
||||
const entries: ProviderUsageEntry[] = [];
|
||||
for (const provider of settings.providerOrder) {
|
||||
const entry = cache[provider];
|
||||
if (!entry || !entry.usage) continue;
|
||||
if (now - entry.fetchedAt >= ttlMs) continue;
|
||||
const usage = { ...entry.usage, status: entry.status };
|
||||
if (usage.error && isExpectedMissingData(usage.error)) continue;
|
||||
entries.push({ provider, usage });
|
||||
}
|
||||
const payload = JSON.stringify({ provider: controllerState.currentProvider, entries });
|
||||
if (payload === lastAllSnapshot) return;
|
||||
lastAllSnapshot = payload;
|
||||
pi.events.emit("sub-core:update-all", {
|
||||
state: { provider: controllerState.currentProvider, entries },
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeCache = onCacheUpdate((provider, entry) => {
|
||||
if (!controllerState.currentProvider || provider !== controllerState.currentProvider) return;
|
||||
const usage = entry?.usage ? { ...entry.usage, status: entry.status } : undefined;
|
||||
controllerState.cachedUsage = usage;
|
||||
emitCurrentUpdate(controllerState.currentProvider, usage);
|
||||
});
|
||||
|
||||
let stopCacheWatch: (() => void) | undefined;
|
||||
let cacheWatchStarted = false;
|
||||
|
||||
const startCacheWatch = (): void => {
|
||||
if (cacheWatchStarted) return;
|
||||
cacheWatchStarted = true;
|
||||
stopCacheWatch = watchCacheUpdates();
|
||||
};
|
||||
|
||||
function emitUpdate(update: UsageUpdate): void {
|
||||
emitCurrentUpdate(update.provider, update.usage);
|
||||
}
|
||||
|
||||
async function refresh(
|
||||
ctx: ExtensionContext,
|
||||
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
|
||||
) {
|
||||
lastContext = ctx;
|
||||
ensureSettingsLoaded();
|
||||
try {
|
||||
await controller.refresh(ctx, settings, controllerState, emitUpdate, options);
|
||||
} finally {
|
||||
if (!options?.skipFetch) {
|
||||
lastUsageRefreshAt = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus(
|
||||
ctx: ExtensionContext,
|
||||
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
|
||||
) {
|
||||
lastContext = ctx;
|
||||
ensureSettingsLoaded();
|
||||
try {
|
||||
await controller.refreshStatus(ctx, settings, controllerState, emitUpdate, options);
|
||||
} finally {
|
||||
if (!options?.skipFetch) {
|
||||
lastStatusRefreshAt = Date.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cycleProvider(ctx: ExtensionContext): Promise<void> {
|
||||
ensureSettingsLoaded();
|
||||
await controller.cycleProvider(ctx, settings, controllerState, emitUpdate);
|
||||
}
|
||||
|
||||
function setupRefreshInterval(): void {
|
||||
if (usageRefreshInterval) {
|
||||
clearInterval(usageRefreshInterval);
|
||||
usageRefreshInterval = undefined;
|
||||
}
|
||||
if (statusRefreshInterval) {
|
||||
clearInterval(statusRefreshInterval);
|
||||
statusRefreshInterval = undefined;
|
||||
}
|
||||
|
||||
const usageIntervalMs = settings.behavior.refreshInterval * 1000;
|
||||
if (usageIntervalMs > 0) {
|
||||
const usageTickMs = Math.min(usageIntervalMs, 10000);
|
||||
usageRefreshInterval = setInterval(() => {
|
||||
if (!lastContext) return;
|
||||
const elapsed = lastUsageRefreshAt ? Date.now() - lastUsageRefreshAt : usageIntervalMs + 1;
|
||||
if (elapsed >= usageIntervalMs) {
|
||||
void refresh(lastContext);
|
||||
}
|
||||
}, usageTickMs);
|
||||
usageRefreshInterval.unref?.();
|
||||
}
|
||||
|
||||
const statusIntervalMs = settings.statusRefresh.refreshInterval * 1000;
|
||||
if (statusIntervalMs > 0) {
|
||||
const statusTickMs = Math.min(statusIntervalMs, 10000);
|
||||
statusRefreshInterval = setInterval(() => {
|
||||
if (!lastContext) return;
|
||||
const elapsed = lastStatusRefreshAt ? Date.now() - lastStatusRefreshAt : statusIntervalMs + 1;
|
||||
if (elapsed >= statusIntervalMs) {
|
||||
void refreshStatus(lastContext);
|
||||
}
|
||||
}, statusTickMs);
|
||||
statusRefreshInterval.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
function applySettingsPatch(patch: Partial<Settings>): void {
|
||||
ensureSettingsLoaded();
|
||||
settings = deepMerge(settings, patch);
|
||||
saveSettings(settings);
|
||||
setupRefreshInterval();
|
||||
pi.events.emit("sub-core:settings:updated", { settings });
|
||||
}
|
||||
|
||||
function readSettingsFile(): string | undefined {
|
||||
try {
|
||||
return fs.readFileSync(SETTINGS_PATH, "utf-8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function applySettingsFromDisk(): void {
|
||||
clearSettingsCache();
|
||||
settings = loadSettings();
|
||||
registerToolsFromSettings(settings);
|
||||
setupRefreshInterval();
|
||||
pi.events.emit("sub-core:settings:updated", { settings });
|
||||
if (lastContext) {
|
||||
void refresh(lastContext, { allowStaleCache: true, skipFetch: true });
|
||||
void refreshStatus(lastContext, { allowStaleCache: true, skipFetch: true });
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSettingsSnapshot(): void {
|
||||
const content = readSettingsFile();
|
||||
if (!content || content === settingsSnapshot) return;
|
||||
try {
|
||||
JSON.parse(content);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
settingsSnapshot = content;
|
||||
applySettingsFromDisk();
|
||||
}
|
||||
|
||||
function checkSettingsFile(): void {
|
||||
try {
|
||||
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.mtimeMs) return;
|
||||
if (stat.mtimeMs === settingsMtimeMs) return;
|
||||
settingsMtimeMs = stat.mtimeMs;
|
||||
refreshSettingsSnapshot();
|
||||
} catch {
|
||||
// Ignore missing files
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSettingsRefresh(): void {
|
||||
if (settingsDebounce) clearTimeout(settingsDebounce);
|
||||
settingsDebounce = setTimeout(() => checkSettingsFile(), 200);
|
||||
}
|
||||
|
||||
function startSettingsWatch(): void {
|
||||
if (settingsWatchStarted) return;
|
||||
settingsWatchStarted = true;
|
||||
if (!settingsSnapshot) {
|
||||
const content = readSettingsFile();
|
||||
if (content) {
|
||||
settingsSnapshot = content;
|
||||
try {
|
||||
const stat = fs.statSync(SETTINGS_PATH, { throwIfNoEntry: false });
|
||||
if (stat?.mtimeMs) settingsMtimeMs = stat.mtimeMs;
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
settingsWatcher = fs.watch(SETTINGS_PATH, scheduleSettingsRefresh);
|
||||
settingsWatcher.unref?.();
|
||||
} catch {
|
||||
settingsWatcher = undefined;
|
||||
}
|
||||
settingsPoll = setInterval(() => checkSettingsFile(), 2000);
|
||||
settingsPoll.unref?.();
|
||||
}
|
||||
|
||||
async function getEntries(force?: boolean): Promise<ProviderUsageEntry[]> {
|
||||
ensureSettingsLoaded();
|
||||
const enabledProviders = controller.getEnabledProviders(settings);
|
||||
if (enabledProviders.length === 0) return [];
|
||||
if (force) {
|
||||
return fetchUsageEntries(deps, settings, enabledProviders, { force: true });
|
||||
}
|
||||
return getCachedUsageEntries(enabledProviders, settings);
|
||||
}
|
||||
|
||||
const registerUsageTool = (name: ToolName): void => {
|
||||
pi.registerTool({
|
||||
name,
|
||||
label: "Sub Usage",
|
||||
description: "Refresh and return the latest subscription usage snapshot.",
|
||||
parameters: Type.Object({
|
||||
force: Type.Optional(Type.Boolean({ description: "Force refresh" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const { force } = params as { force?: boolean };
|
||||
await refresh(ctx, { force: force ?? true });
|
||||
const payload = { provider: lastState.provider, usage: stripUsageProvider(lastState.usage) };
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
details: payload,
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const registerAllUsageTool = (name: ToolName): void => {
|
||||
pi.registerTool({
|
||||
name,
|
||||
label: "Sub All Usage",
|
||||
description: "Refresh and return usage snapshots for all enabled providers.",
|
||||
parameters: Type.Object({
|
||||
force: Type.Optional(Type.Boolean({ description: "Force refresh" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
|
||||
const { force } = params as { force?: boolean };
|
||||
const entries = await getEntries(force ?? true);
|
||||
const payload = entries.map((entry) => ({
|
||||
provider: entry.provider,
|
||||
usage: stripUsageProvider(entry.usage),
|
||||
}));
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
details: { entries: payload },
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function registerToolsFromSettings(nextSettings: Settings): void {
|
||||
if (toolsRegistered) return;
|
||||
const usageToolEnabled = nextSettings.tools?.usageTool ?? false;
|
||||
const allUsageToolEnabled = nextSettings.tools?.allUsageTool ?? false;
|
||||
|
||||
if (usageToolEnabled) {
|
||||
for (const name of TOOL_NAMES.usage) {
|
||||
registerUsageTool(name);
|
||||
}
|
||||
}
|
||||
if (allUsageToolEnabled) {
|
||||
for (const name of TOOL_NAMES.allUsage) {
|
||||
registerAllUsageTool(name);
|
||||
}
|
||||
}
|
||||
toolsRegistered = true;
|
||||
}
|
||||
|
||||
function ensureSettingsLoaded(): void {
|
||||
if (settingsLoaded) return;
|
||||
settings = loadSettings();
|
||||
settingsLoaded = true;
|
||||
registerToolsFromSettings(settings);
|
||||
setupRefreshInterval();
|
||||
const watchTimer = setTimeout(() => {
|
||||
startCacheWatch();
|
||||
startSettingsWatch();
|
||||
}, 0);
|
||||
watchTimer.unref?.();
|
||||
}
|
||||
pi.registerCommand("sub-core:settings", {
|
||||
description: "Open sub-core settings",
|
||||
handler: async (_args, ctx) => {
|
||||
ensureSettingsLoaded();
|
||||
const handleSettingsChange = async (updatedSettings: Settings) => {
|
||||
applySettingsPatch(updatedSettings);
|
||||
if (lastContext) {
|
||||
await refresh(lastContext);
|
||||
}
|
||||
};
|
||||
|
||||
const newSettings = await showSettingsUI(ctx, handleSettingsChange);
|
||||
settings = newSettings;
|
||||
applySettingsPatch(newSettings);
|
||||
if (lastContext) {
|
||||
await refresh(lastContext);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.events.on("sub-core:request", async (payload) => {
|
||||
ensureSettingsLoaded();
|
||||
const request = payload as SubCoreRequest;
|
||||
if (request.type === "entries") {
|
||||
const entries = await getEntries(request.force);
|
||||
if (lastContext && settings.statusRefresh.refreshInterval > 0) {
|
||||
await refreshStatus(lastContext, { force: request.force });
|
||||
}
|
||||
request.reply({ entries });
|
||||
return;
|
||||
}
|
||||
request.reply({
|
||||
state: lastState,
|
||||
settings: request.includeSettings ? settings : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
pi.events.on("sub-core:settings:patch", (payload) => {
|
||||
const patch = (payload as { patch?: Partial<Settings> }).patch;
|
||||
if (!patch) return;
|
||||
applySettingsPatch(patch);
|
||||
if (lastContext) {
|
||||
void refresh(lastContext);
|
||||
}
|
||||
});
|
||||
|
||||
pi.events.on("sub-core:action", (payload) => {
|
||||
const action = payload as SubCoreAction;
|
||||
if (!lastContext) return;
|
||||
switch (action.type) {
|
||||
case "refresh":
|
||||
void refresh(lastContext, { force: action.force });
|
||||
break;
|
||||
case "cycleProvider":
|
||||
void cycleProvider(lastContext);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
lastContext = ctx;
|
||||
ensureSettingsLoaded();
|
||||
void refresh(ctx, { allowStaleCache: true, skipFetch: true });
|
||||
void refreshStatus(ctx, { allowStaleCache: true, skipFetch: true });
|
||||
pi.events.emit("sub-core:ready", { state: lastState, settings });
|
||||
});
|
||||
|
||||
pi.on("turn_start", async (_event, ctx) => {
|
||||
if (settings.behavior.refreshOnTurnStart) {
|
||||
await refresh(ctx);
|
||||
}
|
||||
if (settings.statusRefresh.refreshOnTurnStart) {
|
||||
await refreshStatus(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("tool_result", async (_event, ctx) => {
|
||||
if (settings.behavior.refreshOnToolResult) {
|
||||
await refresh(ctx, { force: true });
|
||||
}
|
||||
if (settings.statusRefresh.refreshOnToolResult) {
|
||||
await refreshStatus(ctx, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
await refresh(ctx, { force: true });
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
controllerState.currentProvider = undefined;
|
||||
controllerState.cachedUsage = undefined;
|
||||
await refresh(ctx);
|
||||
await refreshStatus(ctx);
|
||||
});
|
||||
|
||||
pi.on("session_branch" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
||||
controllerState.currentProvider = undefined;
|
||||
controllerState.cachedUsage = undefined;
|
||||
await refresh(ctx);
|
||||
await refreshStatus(ctx);
|
||||
});
|
||||
|
||||
pi.on("model_select" as unknown as "session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
||||
controllerState.currentProvider = undefined;
|
||||
controllerState.cachedUsage = undefined;
|
||||
void refresh(ctx, { force: true, allowStaleCache: true });
|
||||
void refreshStatus(ctx, { force: true, allowStaleCache: true });
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
if (usageRefreshInterval) {
|
||||
clearInterval(usageRefreshInterval);
|
||||
usageRefreshInterval = undefined;
|
||||
}
|
||||
if (statusRefreshInterval) {
|
||||
clearInterval(statusRefreshInterval);
|
||||
statusRefreshInterval = undefined;
|
||||
}
|
||||
if (settingsDebounce) {
|
||||
clearTimeout(settingsDebounce);
|
||||
settingsDebounce = undefined;
|
||||
}
|
||||
if (settingsPoll) {
|
||||
clearInterval(settingsPoll);
|
||||
settingsPoll = undefined;
|
||||
}
|
||||
settingsWatcher?.close();
|
||||
settingsWatcher = undefined;
|
||||
settingsWatchStarted = false;
|
||||
settingsSnapshot = "";
|
||||
settingsMtimeMs = 0;
|
||||
unsubscribeCache();
|
||||
unsubscribeCacheSnapshot();
|
||||
stopCacheWatch?.();
|
||||
stopCacheWatch = undefined;
|
||||
cacheWatchStarted = false;
|
||||
lastContext = undefined;
|
||||
subCoreGlobal.__piSubCore = undefined;
|
||||
});
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "@marckrenn/pi-sub-core",
|
||||
"version": "1.3.0",
|
||||
"description": "Shared usage data core for pi extensions",
|
||||
"keywords": [
|
||||
"pi-package"
|
||||
],
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org"
|
||||
},
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"check": "tsc --noEmit",
|
||||
"check:watch": "tsc --noEmit --watch",
|
||||
"test": "tsx test/all.test.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marckrenn/pi-sub-shared": "^1.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mariozechner/pi-coding-agent": "*"
|
||||
}
|
||||
}
|
||||
@@ -1,489 +0,0 @@
|
||||
/**
|
||||
* Cache management for sub-bar
|
||||
* Shared cache across all pi instances to avoid redundant API calls
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import type { ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
|
||||
import { isExpectedMissingData } from "./errors.js";
|
||||
import { getStorage } from "./storage.js";
|
||||
import {
|
||||
getCachePath,
|
||||
getCacheLockPath,
|
||||
getLegacyAgentCacheLockPath,
|
||||
getLegacyAgentCachePath,
|
||||
getLegacyCacheLockPath,
|
||||
getLegacyCachePath,
|
||||
} from "./paths.js";
|
||||
import { tryAcquireFileLock, releaseFileLock, waitForLockRelease } from "./storage/lock.js";
|
||||
|
||||
/**
|
||||
* Cache entry for a provider
|
||||
*/
|
||||
export interface CacheEntry {
|
||||
fetchedAt: number;
|
||||
statusFetchedAt?: number;
|
||||
usage?: UsageSnapshot;
|
||||
status?: ProviderStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache structure
|
||||
*/
|
||||
export interface Cache {
|
||||
[provider: string]: CacheEntry;
|
||||
}
|
||||
|
||||
export type CacheUpdateListener = (provider: ProviderName, entry?: CacheEntry) => void;
|
||||
export type CacheSnapshotListener = (cache: Cache) => void;
|
||||
|
||||
const cacheUpdateListeners = new Set<CacheUpdateListener>();
|
||||
const cacheSnapshotListeners = new Set<CacheSnapshotListener>();
|
||||
|
||||
let lastCacheSnapshot: Cache | null = null;
|
||||
let lastCacheContent = "";
|
||||
let lastCacheMtimeMs = 0;
|
||||
let legacyCacheMigrated = false;
|
||||
|
||||
function updateCacheSnapshot(cache: Cache, content: string, mtimeMs: number): void {
|
||||
lastCacheSnapshot = cache;
|
||||
lastCacheContent = content;
|
||||
lastCacheMtimeMs = mtimeMs;
|
||||
}
|
||||
|
||||
function resetCacheSnapshot(): void {
|
||||
lastCacheSnapshot = {};
|
||||
lastCacheContent = "";
|
||||
lastCacheMtimeMs = 0;
|
||||
}
|
||||
|
||||
function migrateLegacyCache(): void {
|
||||
if (legacyCacheMigrated) return;
|
||||
legacyCacheMigrated = true;
|
||||
const storage = getStorage();
|
||||
try {
|
||||
const legacyCachePaths = [LEGACY_AGENT_CACHE_PATH, LEGACY_CACHE_PATH];
|
||||
if (!storage.exists(CACHE_PATH)) {
|
||||
const legacyPath = legacyCachePaths.find((path) => storage.exists(path));
|
||||
if (legacyPath) {
|
||||
const content = storage.readFile(legacyPath);
|
||||
if (content) {
|
||||
ensureCacheDir();
|
||||
storage.writeFile(CACHE_PATH, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const legacyPath of legacyCachePaths) {
|
||||
if (storage.exists(legacyPath)) {
|
||||
storage.removeFile(legacyPath);
|
||||
}
|
||||
}
|
||||
for (const legacyLockPath of [LEGACY_AGENT_LOCK_PATH, LEGACY_LOCK_PATH]) {
|
||||
if (storage.exists(legacyLockPath)) {
|
||||
storage.removeFile(legacyLockPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to migrate cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export function onCacheUpdate(listener: CacheUpdateListener): () => void {
|
||||
cacheUpdateListeners.add(listener);
|
||||
return () => {
|
||||
cacheUpdateListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function onCacheSnapshot(listener: CacheSnapshotListener): () => void {
|
||||
cacheSnapshotListeners.add(listener);
|
||||
return () => {
|
||||
cacheSnapshotListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function emitCacheUpdate(provider: ProviderName, entry?: CacheEntry): void {
|
||||
for (const listener of cacheUpdateListeners) {
|
||||
try {
|
||||
listener(provider, entry);
|
||||
} catch (error) {
|
||||
console.error("Failed to notify cache update:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emitCacheSnapshot(cache: Cache): void {
|
||||
for (const listener of cacheSnapshotListeners) {
|
||||
try {
|
||||
listener(cache);
|
||||
} catch (error) {
|
||||
console.error("Failed to notify cache snapshot:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache file path
|
||||
*/
|
||||
export const CACHE_PATH = getCachePath();
|
||||
const LEGACY_CACHE_PATH = getLegacyCachePath();
|
||||
const LEGACY_AGENT_CACHE_PATH = getLegacyAgentCachePath();
|
||||
|
||||
/**
|
||||
* Lock file path
|
||||
*/
|
||||
const LOCK_PATH = getCacheLockPath();
|
||||
const LEGACY_LOCK_PATH = getLegacyCacheLockPath();
|
||||
const LEGACY_AGENT_LOCK_PATH = getLegacyAgentCacheLockPath();
|
||||
|
||||
/**
|
||||
* Lock timeout in milliseconds
|
||||
*/
|
||||
const LOCK_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Ensure cache directory exists
|
||||
*/
|
||||
function ensureCacheDir(): void {
|
||||
const storage = getStorage();
|
||||
const dir = path.dirname(CACHE_PATH);
|
||||
storage.ensureDir(dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cache from disk
|
||||
*/
|
||||
export function readCache(): Cache {
|
||||
migrateLegacyCache();
|
||||
const storage = getStorage();
|
||||
try {
|
||||
const cacheExists = storage.exists(CACHE_PATH);
|
||||
if (!cacheExists) {
|
||||
if (lastCacheMtimeMs !== 0 || lastCacheContent) {
|
||||
resetCacheSnapshot();
|
||||
}
|
||||
return lastCacheSnapshot ?? {};
|
||||
}
|
||||
|
||||
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
||||
if (stat && stat.mtimeMs === lastCacheMtimeMs && lastCacheSnapshot) {
|
||||
return lastCacheSnapshot;
|
||||
}
|
||||
|
||||
const content = storage.readFile(CACHE_PATH);
|
||||
if (!content) {
|
||||
updateCacheSnapshot({}, "", stat?.mtimeMs ?? 0);
|
||||
return {};
|
||||
}
|
||||
if (!stat && content === lastCacheContent && lastCacheSnapshot) {
|
||||
return lastCacheSnapshot;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(content) as Cache;
|
||||
updateCacheSnapshot(parsed, content, stat?.mtimeMs ?? Date.now());
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
const lastBrace = content.lastIndexOf("}");
|
||||
if (lastBrace > 0) {
|
||||
const trimmed = content.slice(0, lastBrace + 1);
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as Cache;
|
||||
if (stat) {
|
||||
writeCache(parsed);
|
||||
} else {
|
||||
updateCacheSnapshot(parsed, trimmed, Date.now());
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
// fall through to log below
|
||||
}
|
||||
}
|
||||
console.error("Failed to read cache:", error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to read cache:", error);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Write cache to disk
|
||||
*/
|
||||
function writeCache(cache: Cache): void {
|
||||
migrateLegacyCache();
|
||||
const storage = getStorage();
|
||||
try {
|
||||
ensureCacheDir();
|
||||
const content = JSON.stringify(cache, null, 2);
|
||||
const cacheExists = storage.exists(CACHE_PATH);
|
||||
if (cacheExists && content === lastCacheContent) {
|
||||
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
||||
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? lastCacheMtimeMs);
|
||||
return;
|
||||
}
|
||||
const tempPath = `${CACHE_PATH}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(tempPath, content, "utf-8");
|
||||
fs.renameSync(tempPath, CACHE_PATH);
|
||||
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
||||
updateCacheSnapshot(cache, content, stat?.mtimeMs ?? Date.now());
|
||||
} catch (error) {
|
||||
console.error("Failed to write cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface CacheWatchOptions {
|
||||
debounceMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
lockRetryMs?: number;
|
||||
}
|
||||
|
||||
export function watchCacheUpdates(options?: CacheWatchOptions): () => void {
|
||||
migrateLegacyCache();
|
||||
const debounceMs = options?.debounceMs ?? 250;
|
||||
const pollIntervalMs = options?.pollIntervalMs ?? 5000;
|
||||
const lockRetryMs = options?.lockRetryMs ?? 1000;
|
||||
let debounceTimer: NodeJS.Timeout | undefined;
|
||||
let pollTimer: NodeJS.Timeout | undefined;
|
||||
let lockRetryPending = false;
|
||||
let lastSnapshot = "";
|
||||
let lastMtimeMs = 0;
|
||||
let stopped = false;
|
||||
|
||||
const scheduleLockRetry = () => {
|
||||
if (lockRetryPending || stopped) return;
|
||||
lockRetryPending = true;
|
||||
void waitForLockRelease(LOCK_PATH, lockRetryMs).then((released) => {
|
||||
lockRetryPending = false;
|
||||
if (released) {
|
||||
emitFromCache();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const emitFromCache = () => {
|
||||
try {
|
||||
if (fs.existsSync(LOCK_PATH)) {
|
||||
scheduleLockRetry();
|
||||
return;
|
||||
}
|
||||
const stat = fs.statSync(CACHE_PATH, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.mtimeMs) return;
|
||||
if (stat.mtimeMs === lastMtimeMs) return;
|
||||
lastMtimeMs = stat.mtimeMs;
|
||||
const content = fs.readFileSync(CACHE_PATH, "utf-8");
|
||||
if (content === lastSnapshot) return;
|
||||
lastSnapshot = content;
|
||||
const cache = JSON.parse(content) as Cache;
|
||||
updateCacheSnapshot(cache, content, stat.mtimeMs);
|
||||
emitCacheSnapshot(cache);
|
||||
for (const [provider, entry] of Object.entries(cache)) {
|
||||
emitCacheUpdate(provider as ProviderName, entry);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse or read errors (likely mid-write)
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleEmit = () => {
|
||||
if (stopped) return;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => emitFromCache(), debounceMs);
|
||||
};
|
||||
|
||||
let watcher: fs.FSWatcher | undefined;
|
||||
try {
|
||||
watcher = fs.watch(CACHE_PATH, scheduleEmit);
|
||||
watcher.unref?.();
|
||||
} catch {
|
||||
watcher = undefined;
|
||||
}
|
||||
|
||||
pollTimer = setInterval(() => emitFromCache(), pollIntervalMs);
|
||||
pollTimer.unref?.();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
watcher?.close();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for lock to be released and re-check cache
|
||||
* Returns the cache entry if it became fresh while waiting
|
||||
*/
|
||||
async function waitForLockAndRecheck(
|
||||
provider: ProviderName,
|
||||
ttlMs: number,
|
||||
maxWaitMs: number = 3000
|
||||
): Promise<CacheEntry | null> {
|
||||
const released = await waitForLockRelease(LOCK_PATH, maxWaitMs);
|
||||
if (!released) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cache = readCache();
|
||||
const entry = cache[provider];
|
||||
if (entry && entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
|
||||
return null;
|
||||
}
|
||||
if (entry && Date.now() - entry.fetchedAt < ttlMs) {
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached data for a provider if fresh, or null if stale/missing
|
||||
*/
|
||||
export async function getCachedData(
|
||||
provider: ProviderName,
|
||||
ttlMs: number,
|
||||
cacheSnapshot?: Cache
|
||||
): Promise<CacheEntry | null> {
|
||||
const cache = cacheSnapshot ?? readCache();
|
||||
const entry = cache[provider];
|
||||
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.usage?.error && !isExpectedMissingData(entry.usage.error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const age = Date.now() - entry.fetchedAt;
|
||||
if (age < ttlMs) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch data with lock coordination
|
||||
* Returns cached data if fresh, or executes fetchFn if cache is stale
|
||||
*/
|
||||
export async function fetchWithCache<T extends { usage?: UsageSnapshot; status?: ProviderStatus; statusFetchedAt?: number }>(
|
||||
provider: ProviderName,
|
||||
ttlMs: number,
|
||||
fetchFn: () => Promise<T>,
|
||||
options?: { force?: boolean }
|
||||
): Promise<T> {
|
||||
const forceRefresh = options?.force === true;
|
||||
|
||||
if (!forceRefresh) {
|
||||
// Check cache first
|
||||
const cached = await getCachedData(provider, ttlMs);
|
||||
if (cached) {
|
||||
return { usage: cached.usage, status: cached.status } as T;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache is stale or forced refresh, try to acquire lock
|
||||
const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
|
||||
|
||||
if (!lockAcquired) {
|
||||
// Another process is fetching, wait and re-check cache
|
||||
const freshEntry = await waitForLockAndRecheck(provider, ttlMs);
|
||||
if (freshEntry) {
|
||||
return { usage: freshEntry.usage, status: freshEntry.status } as T;
|
||||
}
|
||||
// Timeout or cache still stale, fetch anyway
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch fresh data
|
||||
const result = await fetchFn();
|
||||
|
||||
// Only cache if we got valid usage data (not just no-credentials/errors)
|
||||
const hasCredentialError = result.usage?.error && isExpectedMissingData(result.usage.error);
|
||||
const hasError = Boolean(result.usage?.error);
|
||||
const shouldCache = result.usage && !hasCredentialError && !hasError;
|
||||
|
||||
const cache = readCache();
|
||||
|
||||
if (shouldCache) {
|
||||
// Update cache with valid data
|
||||
const fetchedAt = Date.now();
|
||||
const previous = cache[provider];
|
||||
const statusFetchedAt = result.statusFetchedAt ?? (result.status ? fetchedAt : previous?.statusFetchedAt);
|
||||
cache[provider] = {
|
||||
fetchedAt,
|
||||
statusFetchedAt,
|
||||
usage: result.usage,
|
||||
status: result.status,
|
||||
};
|
||||
writeCache(cache);
|
||||
emitCacheUpdate(provider, cache[provider]);
|
||||
emitCacheSnapshot(cache);
|
||||
} else if (hasCredentialError) {
|
||||
// Remove from cache if no credentials
|
||||
if (cache[provider]) {
|
||||
delete cache[provider];
|
||||
writeCache(cache);
|
||||
emitCacheUpdate(provider, undefined);
|
||||
emitCacheSnapshot(cache);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
releaseFileLock(LOCK_PATH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateCacheStatus(
|
||||
provider: ProviderName,
|
||||
status: ProviderStatus,
|
||||
options?: { statusFetchedAt?: number }
|
||||
): Promise<void> {
|
||||
const lockAcquired = tryAcquireFileLock(LOCK_PATH, LOCK_TIMEOUT_MS);
|
||||
if (!lockAcquired) {
|
||||
await waitForLockRelease(LOCK_PATH, 3000);
|
||||
}
|
||||
try {
|
||||
const cache = readCache();
|
||||
const entry = cache[provider];
|
||||
const statusFetchedAt = options?.statusFetchedAt ?? Date.now();
|
||||
cache[provider] = {
|
||||
fetchedAt: entry?.fetchedAt ?? 0,
|
||||
statusFetchedAt,
|
||||
usage: entry?.usage,
|
||||
status,
|
||||
};
|
||||
writeCache(cache);
|
||||
emitCacheUpdate(provider, cache[provider]);
|
||||
emitCacheSnapshot(cache);
|
||||
} finally {
|
||||
if (lockAcquired) {
|
||||
releaseFileLock(LOCK_PATH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache for a specific provider or all providers
|
||||
*/
|
||||
export function clearCache(provider?: ProviderName): void {
|
||||
const storage = getStorage();
|
||||
if (provider) {
|
||||
const cache = readCache();
|
||||
delete cache[provider];
|
||||
writeCache(cache);
|
||||
} else {
|
||||
try {
|
||||
if (storage.exists(CACHE_PATH)) {
|
||||
storage.removeFile(CACHE_PATH);
|
||||
}
|
||||
resetCacheSnapshot();
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* Configuration constants for the sub-bar extension
|
||||
*/
|
||||
|
||||
/**
|
||||
* Google Workspace status API endpoint
|
||||
*/
|
||||
export const GOOGLE_STATUS_URL = "https://www.google.com/appsstatus/dashboard/incidents.json";
|
||||
|
||||
/**
|
||||
* Google product ID for Gemini in the status API
|
||||
*/
|
||||
export const GEMINI_PRODUCT_ID = "npdyhgECDJ6tB66MxXyo";
|
||||
|
||||
/**
|
||||
* Model multipliers for Copilot request counting
|
||||
* Maps model display names to their request multiplier
|
||||
*/
|
||||
export { MODEL_MULTIPLIERS } from "../../src/shared.js";
|
||||
|
||||
/**
|
||||
* Timeout for API requests in milliseconds
|
||||
*/
|
||||
export const API_TIMEOUT_MS = 5000;
|
||||
|
||||
/**
|
||||
* Timeout for CLI commands in milliseconds
|
||||
*/
|
||||
export const CLI_TIMEOUT_MS = 10000;
|
||||
|
||||
/**
|
||||
* Interval for automatic usage refresh in milliseconds
|
||||
*/
|
||||
export const REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/**
|
||||
* Default dependencies using real implementations
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
|
||||
import type { Dependencies } from "./types.js";
|
||||
|
||||
/**
|
||||
* Create default dependencies using Node.js APIs
|
||||
*/
|
||||
export function createDefaultDependencies(): Dependencies {
|
||||
return {
|
||||
fetch: globalThis.fetch,
|
||||
readFile: (path: string) => {
|
||||
try {
|
||||
return fs.readFileSync(path, "utf-8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
fileExists: (path: string) => {
|
||||
try {
|
||||
return fs.existsSync(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => {
|
||||
return execFileSync(file, args, options) as string;
|
||||
},
|
||||
homedir: () => os.homedir(),
|
||||
env: process.env,
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* Error utilities for the sub-bar extension
|
||||
*/
|
||||
|
||||
import type { UsageError, UsageErrorCode } from "./types.js";
|
||||
|
||||
export function createError(code: UsageErrorCode, message: string, httpStatus?: number): UsageError {
|
||||
return { code, message, httpStatus };
|
||||
}
|
||||
|
||||
export function noCredentials(): UsageError {
|
||||
return createError("NO_CREDENTIALS", "No credentials found");
|
||||
}
|
||||
|
||||
export function noCli(cliName: string): UsageError {
|
||||
return createError("NO_CLI", `${cliName} CLI not found`);
|
||||
}
|
||||
|
||||
export function notLoggedIn(): UsageError {
|
||||
return createError("NOT_LOGGED_IN", "Not logged in");
|
||||
}
|
||||
|
||||
export function fetchFailed(reason?: string): UsageError {
|
||||
return createError("FETCH_FAILED", reason ?? "Fetch failed");
|
||||
}
|
||||
|
||||
export function httpError(status: number): UsageError {
|
||||
return createError("HTTP_ERROR", `HTTP ${status}`, status);
|
||||
}
|
||||
|
||||
export function apiError(message: string): UsageError {
|
||||
return createError("API_ERROR", message);
|
||||
}
|
||||
|
||||
export function timeout(): UsageError {
|
||||
return createError("TIMEOUT", "Request timed out");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should be considered "no data available" vs actual error
|
||||
* These are expected states when provider isn't configured
|
||||
*/
|
||||
export function isExpectedMissingData(error: UsageError): boolean {
|
||||
const ignoreCodes = new Set<UsageErrorCode>(["NO_CREDENTIALS", "NO_CLI", "NOT_LOGGED_IN"]);
|
||||
return ignoreCodes.has(error.code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for display in the usage widget
|
||||
*/
|
||||
export function formatErrorForDisplay(error: UsageError): string {
|
||||
switch (error.code) {
|
||||
case "NO_CREDENTIALS":
|
||||
return "No creds";
|
||||
case "NO_CLI":
|
||||
return "No CLI";
|
||||
case "NOT_LOGGED_IN":
|
||||
return "Not logged in";
|
||||
case "HTTP_ERROR":
|
||||
if (error.httpStatus === 401) {
|
||||
return "token no longer valid – please /login again";
|
||||
}
|
||||
return `${error.httpStatus}`;
|
||||
case "FETCH_FAILED":
|
||||
case "API_ERROR":
|
||||
case "TIMEOUT":
|
||||
case "UNKNOWN":
|
||||
default:
|
||||
return "Fetch failed";
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* Shared path helpers for sub-core storage.
|
||||
*/
|
||||
|
||||
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SETTINGS_FILE_NAME = "pi-sub-core-settings.json";
|
||||
const CACHE_DIR_NAME = "cache";
|
||||
const CACHE_NAMESPACE_DIR = "sub-core";
|
||||
const CACHE_FILE_NAME = "cache.json";
|
||||
const CACHE_LOCK_FILE_NAME = "cache.lock";
|
||||
const LEGACY_AGENT_CACHE_FILE_NAME = "pi-sub-core-cache.json";
|
||||
const LEGACY_AGENT_LOCK_FILE_NAME = "pi-sub-core-cache.lock";
|
||||
|
||||
export function getExtensionDir(): string {
|
||||
return join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
return join(getAgentDir(), CACHE_DIR_NAME, CACHE_NAMESPACE_DIR);
|
||||
}
|
||||
|
||||
export function getCachePath(): string {
|
||||
return join(getCacheDir(), CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
export function getCacheLockPath(): string {
|
||||
return join(getCacheDir(), CACHE_LOCK_FILE_NAME);
|
||||
}
|
||||
|
||||
export function getLegacyCachePath(): string {
|
||||
return join(getExtensionDir(), "cache.json");
|
||||
}
|
||||
|
||||
export function getLegacyCacheLockPath(): string {
|
||||
return join(getExtensionDir(), "cache.lock");
|
||||
}
|
||||
|
||||
export function getLegacyAgentCachePath(): string {
|
||||
return join(getAgentDir(), LEGACY_AGENT_CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
export function getLegacyAgentCacheLockPath(): string {
|
||||
return join(getAgentDir(), LEGACY_AGENT_LOCK_FILE_NAME);
|
||||
}
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
return join(getAgentDir(), SETTINGS_FILE_NAME);
|
||||
}
|
||||
|
||||
export function getLegacySettingsPath(): string {
|
||||
return join(getExtensionDir(), "settings.json");
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Provider interface and registry
|
||||
*/
|
||||
|
||||
import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "./types.js";
|
||||
|
||||
/**
|
||||
* Interface for a usage provider
|
||||
*/
|
||||
export interface UsageProvider {
|
||||
readonly name: ProviderName;
|
||||
readonly displayName: string;
|
||||
|
||||
/**
|
||||
* Fetch current usage data for this provider
|
||||
*/
|
||||
fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
|
||||
|
||||
/**
|
||||
* Fetch current status for this provider (optional)
|
||||
*/
|
||||
fetchStatus?(deps: Dependencies): Promise<ProviderStatus>;
|
||||
|
||||
/**
|
||||
* Check if credentials are available (optional)
|
||||
*/
|
||||
hasCredentials?(deps: Dependencies): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for providers with common functionality
|
||||
*/
|
||||
export abstract class BaseProvider implements UsageProvider {
|
||||
abstract readonly name: ProviderName;
|
||||
abstract readonly displayName: string;
|
||||
|
||||
abstract fetchUsage(deps: Dependencies): Promise<UsageSnapshot>;
|
||||
|
||||
hasCredentials(_deps: Dependencies): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty snapshot with an error
|
||||
*/
|
||||
protected emptySnapshot(error?: import("./types.js").UsageError): UsageSnapshot {
|
||||
return {
|
||||
provider: this.name,
|
||||
displayName: this.displayName,
|
||||
windows: [],
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a snapshot with usage data
|
||||
*/
|
||||
protected snapshot(data: Partial<Omit<UsageSnapshot, "provider" | "displayName">>): UsageSnapshot {
|
||||
return {
|
||||
provider: this.name,
|
||||
displayName: this.displayName,
|
||||
windows: [],
|
||||
...data,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Provider detection helpers.
|
||||
*/
|
||||
|
||||
import type { ProviderName } from "../types.js";
|
||||
import { PROVIDERS } from "../types.js";
|
||||
import { PROVIDER_METADATA } from "./metadata.js";
|
||||
|
||||
interface ProviderDetectionHint {
|
||||
provider: ProviderName;
|
||||
providerTokens: string[];
|
||||
modelTokens: string[];
|
||||
}
|
||||
|
||||
const PROVIDER_DETECTION_HINTS: ProviderDetectionHint[] = PROVIDERS.map((provider) => {
|
||||
const detection = PROVIDER_METADATA[provider].detection ?? { providerTokens: [], modelTokens: [] };
|
||||
return {
|
||||
provider,
|
||||
providerTokens: detection.providerTokens,
|
||||
modelTokens: detection.modelTokens,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Detect the provider from model metadata.
|
||||
*/
|
||||
export function detectProviderFromModel(
|
||||
model: { provider?: string; id?: string } | undefined
|
||||
): ProviderName | undefined {
|
||||
if (!model) return undefined;
|
||||
const providerValue = model.provider?.toLowerCase() || "";
|
||||
const idValue = model.id?.toLowerCase() || "";
|
||||
|
||||
if (providerValue.includes("antigravity") || idValue.includes("antigravity")) {
|
||||
return "antigravity";
|
||||
}
|
||||
|
||||
for (const hint of PROVIDER_DETECTION_HINTS) {
|
||||
if (hint.providerTokens.some((token) => providerValue.includes(token))) {
|
||||
return hint.provider;
|
||||
}
|
||||
}
|
||||
|
||||
for (const hint of PROVIDER_DETECTION_HINTS) {
|
||||
if (hint.modelTokens.some((token) => idValue.includes(token))) {
|
||||
return hint.provider;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* Anthropic/Claude usage provider
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
|
||||
import { formatReset, createTimeoutController } from "../../utils.js";
|
||||
import { API_TIMEOUT_MS } from "../../config.js";
|
||||
import { getSettings } from "../../settings.js";
|
||||
|
||||
/**
|
||||
* Load Claude API token from various sources
|
||||
*/
|
||||
function loadClaudeToken(deps: Dependencies): string | undefined {
|
||||
// Explicit override via env var (useful in CI / menu bar apps)
|
||||
const envToken = deps.env.ANTHROPIC_OAUTH_TOKEN?.trim();
|
||||
if (envToken) return envToken;
|
||||
|
||||
// Try pi auth.json next
|
||||
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
|
||||
try {
|
||||
if (deps.fileExists(piAuthPath)) {
|
||||
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
|
||||
if (data.anthropic?.access) return data.anthropic.access;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// Try macOS Keychain (Claude Code credentials)
|
||||
try {
|
||||
const keychainData = deps.execFileSync(
|
||||
"security",
|
||||
["find-generic-password", "-s", "Claude Code-credentials", "-w"],
|
||||
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
|
||||
).trim();
|
||||
if (keychainData) {
|
||||
const parsed = JSON.parse(keychainData);
|
||||
const scopes = parsed.claudeAiOauth?.scopes || [];
|
||||
if (scopes.includes("user:profile") && parsed.claudeAiOauth?.accessToken) {
|
||||
return parsed.claudeAiOauth.accessToken;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keychain access failed
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type ExtraUsageFormat = {
|
||||
symbol: string;
|
||||
decimalSeparator: "." | ",";
|
||||
};
|
||||
|
||||
function getExtraUsageFormat(): ExtraUsageFormat {
|
||||
const settings = getSettings();
|
||||
const providerSettings = settings.providers.anthropic;
|
||||
return {
|
||||
symbol: providerSettings.extraUsageCurrencySymbol?.trim() ?? "",
|
||||
decimalSeparator: providerSettings.extraUsageDecimalSeparator === "," ? "," : ".",
|
||||
};
|
||||
}
|
||||
|
||||
function formatExtraUsageCredits(credits: number, format: ExtraUsageFormat): string {
|
||||
const amount = (credits / 100).toFixed(2);
|
||||
const formatted = format.decimalSeparator === "," ? amount.replace(".", ",") : amount;
|
||||
return format.symbol ? `${format.symbol}${formatted}` : formatted;
|
||||
}
|
||||
|
||||
|
||||
export class AnthropicProvider extends BaseProvider {
|
||||
readonly name = "anthropic" as const;
|
||||
readonly displayName = "Claude Plan";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(loadClaudeToken(deps));
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const token = loadClaudeToken(deps);
|
||||
if (!token) {
|
||||
return this.emptySnapshot(noCredentials());
|
||||
}
|
||||
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await deps.fetch("https://api.anthropic.com/api/oauth/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clear();
|
||||
|
||||
if (!res.ok) {
|
||||
return this.emptySnapshot(httpError(res.status));
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
five_hour?: { utilization?: number; resets_at?: string };
|
||||
seven_day?: { utilization?: number; resets_at?: string };
|
||||
extra_usage?: {
|
||||
is_enabled?: boolean;
|
||||
used_credits?: number;
|
||||
monthly_limit?: number;
|
||||
utilization?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
|
||||
if (data.five_hour?.utilization !== undefined) {
|
||||
const resetAt = data.five_hour.resets_at ? new Date(data.five_hour.resets_at) : undefined;
|
||||
windows.push({
|
||||
label: "5h",
|
||||
usedPercent: data.five_hour.utilization,
|
||||
resetDescription: resetAt ? formatReset(resetAt) : undefined,
|
||||
resetAt: resetAt?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (data.seven_day?.utilization !== undefined) {
|
||||
const resetAt = data.seven_day.resets_at ? new Date(data.seven_day.resets_at) : undefined;
|
||||
windows.push({
|
||||
label: "Week",
|
||||
usedPercent: data.seven_day.utilization,
|
||||
resetDescription: resetAt ? formatReset(resetAt) : undefined,
|
||||
resetAt: resetAt?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Extra usage
|
||||
const extraUsageEnabled = data.extra_usage?.is_enabled === true;
|
||||
const fiveHourUsage = data.five_hour?.utilization ?? 0;
|
||||
|
||||
if (extraUsageEnabled) {
|
||||
const extra = data.extra_usage!;
|
||||
const usedCredits = extra.used_credits || 0;
|
||||
const monthlyLimit = extra.monthly_limit;
|
||||
const utilization = extra.utilization || 0;
|
||||
const format = getExtraUsageFormat();
|
||||
// "active" when 5h >= 99%, otherwise "on"
|
||||
const extraStatus = fiveHourUsage >= 99 ? "active" : "on";
|
||||
let label: string;
|
||||
if (monthlyLimit && monthlyLimit > 0) {
|
||||
label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}/${formatExtraUsageCredits(monthlyLimit, format)}`;
|
||||
} else {
|
||||
label = `Extra [${extraStatus}] ${formatExtraUsageCredits(usedCredits, format)}`;
|
||||
}
|
||||
|
||||
windows.push({
|
||||
label,
|
||||
usedPercent: utilization,
|
||||
resetDescription: extraStatus === "active" ? "__ACTIVE__" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return this.snapshot({
|
||||
windows,
|
||||
extraUsageEnabled,
|
||||
fiveHourUsage,
|
||||
});
|
||||
} catch {
|
||||
clear();
|
||||
return this.emptySnapshot(fetchFailed());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* Google Antigravity usage provider
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
|
||||
import { createTimeoutController, formatReset } from "../../utils.js";
|
||||
import { API_TIMEOUT_MS } from "../../config.js";
|
||||
|
||||
const ANTIGRAVITY_ENDPOINTS = [
|
||||
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
||||
"https://cloudcode-pa.googleapis.com",
|
||||
] as const;
|
||||
|
||||
const ANTIGRAVITY_HEADERS = {
|
||||
"User-Agent": "antigravity/1.11.5 darwin/arm64",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
||||
"Client-Metadata": JSON.stringify({
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
}),
|
||||
};
|
||||
|
||||
const ANTIGRAVITY_HIDDEN_MODELS = new Set(["tab_flash_lite_preview"]);
|
||||
|
||||
interface AntigravityAuth {
|
||||
access?: string;
|
||||
accessToken?: string;
|
||||
token?: string;
|
||||
key?: string;
|
||||
projectId?: string;
|
||||
project?: string;
|
||||
}
|
||||
|
||||
interface CloudCodeQuotaResponse {
|
||||
models?: Record<string, {
|
||||
displayName?: string;
|
||||
model?: string;
|
||||
isInternal?: boolean;
|
||||
quotaInfo?: {
|
||||
remainingFraction?: number;
|
||||
limit?: string;
|
||||
resetTime?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
interface ParsedModelQuota {
|
||||
name: string;
|
||||
remainingFraction: number;
|
||||
resetAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Antigravity access token from auth.json
|
||||
*/
|
||||
function loadAntigravityAuth(deps: Dependencies): AntigravityAuth | undefined {
|
||||
// Explicit override via env var
|
||||
const envProjectId = (deps.env.GOOGLE_ANTIGRAVITY_PROJECT_ID || deps.env.GOOGLE_ANTIGRAVITY_PROJECT)?.trim();
|
||||
const envToken = (deps.env.GOOGLE_ANTIGRAVITY_OAUTH_TOKEN || deps.env.ANTIGRAVITY_OAUTH_TOKEN)?.trim();
|
||||
if (envToken) {
|
||||
return { token: envToken, projectId: envProjectId || undefined };
|
||||
}
|
||||
|
||||
// Also support passing pi-ai style JSON api key: { token, projectId }
|
||||
const envApiKey = (deps.env.GOOGLE_ANTIGRAVITY_API_KEY || deps.env.ANTIGRAVITY_API_KEY)?.trim();
|
||||
if (envApiKey) {
|
||||
try {
|
||||
const parsed = JSON.parse(envApiKey) as { token?: string; projectId?: string };
|
||||
if (parsed?.token) {
|
||||
return { token: parsed.token, projectId: parsed.projectId || envProjectId || undefined };
|
||||
}
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
return { token: envApiKey, projectId: envProjectId || undefined };
|
||||
}
|
||||
|
||||
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
|
||||
try {
|
||||
if (deps.fileExists(piAuthPath)) {
|
||||
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
|
||||
const entry = data["google-antigravity"];
|
||||
if (!entry) return undefined;
|
||||
if (typeof entry === "string") {
|
||||
return { token: entry };
|
||||
}
|
||||
return {
|
||||
access: entry.access,
|
||||
accessToken: entry.accessToken,
|
||||
token: entry.token,
|
||||
key: entry.key,
|
||||
projectId: entry.projectId ?? entry.project,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAntigravityToken(auth: AntigravityAuth | undefined): string | undefined {
|
||||
return auth?.access ?? auth?.accessToken ?? auth?.token ?? auth?.key;
|
||||
}
|
||||
|
||||
function parseResetTime(value?: string): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
return date;
|
||||
}
|
||||
|
||||
function toUsedPercent(remainingFraction: number): number {
|
||||
const fraction = Number.isFinite(remainingFraction) ? remainingFraction : 1;
|
||||
const used = (1 - fraction) * 100;
|
||||
return Math.max(0, Math.min(100, used));
|
||||
}
|
||||
|
||||
async function fetchAntigravityQuota(
|
||||
deps: Dependencies,
|
||||
endpoint: string,
|
||||
token: string,
|
||||
projectId?: string
|
||||
): Promise<{ data?: CloudCodeQuotaResponse; status?: number }> {
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
try {
|
||||
const payload = projectId ? { project: projectId } : {};
|
||||
const res = await deps.fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
...ANTIGRAVITY_HEADERS,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clear();
|
||||
if (!res.ok) return { status: res.status };
|
||||
const data = (await res.json()) as CloudCodeQuotaResponse;
|
||||
return { data };
|
||||
} catch {
|
||||
clear();
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export class AntigravityProvider extends BaseProvider {
|
||||
readonly name = "antigravity" as const;
|
||||
readonly displayName = "Antigravity";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(resolveAntigravityToken(loadAntigravityAuth(deps)));
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const auth = loadAntigravityAuth(deps);
|
||||
const token = resolveAntigravityToken(auth);
|
||||
if (!token) {
|
||||
return this.emptySnapshot(noCredentials());
|
||||
}
|
||||
|
||||
let data: CloudCodeQuotaResponse | undefined;
|
||||
let lastStatus: number | undefined;
|
||||
for (const endpoint of ANTIGRAVITY_ENDPOINTS) {
|
||||
const result = await fetchAntigravityQuota(deps, endpoint, token, auth?.projectId);
|
||||
if (result.data) {
|
||||
data = result.data;
|
||||
break;
|
||||
}
|
||||
if (result.status) {
|
||||
lastStatus = result.status;
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return lastStatus ? this.emptySnapshot(httpError(lastStatus)) : this.emptySnapshot(fetchFailed());
|
||||
}
|
||||
|
||||
const modelByName = new Map<string, ParsedModelQuota>();
|
||||
for (const [modelId, model] of Object.entries(data.models ?? {})) {
|
||||
if (model.isInternal) continue;
|
||||
if (modelId && ANTIGRAVITY_HIDDEN_MODELS.has(modelId.toLowerCase())) continue;
|
||||
const name = model.displayName ?? modelId ?? model.model ?? "unknown";
|
||||
if (!name) continue;
|
||||
if (ANTIGRAVITY_HIDDEN_MODELS.has(name.toLowerCase())) continue;
|
||||
const remainingFraction = model.quotaInfo?.remainingFraction ?? 1;
|
||||
const resetAt = parseResetTime(model.quotaInfo?.resetTime);
|
||||
const existing = modelByName.get(name);
|
||||
if (!existing) {
|
||||
modelByName.set(name, { name, remainingFraction, resetAt });
|
||||
continue;
|
||||
}
|
||||
let next = existing;
|
||||
if (remainingFraction < existing.remainingFraction) {
|
||||
next = { name, remainingFraction, resetAt };
|
||||
} else if (remainingFraction === existing.remainingFraction && resetAt) {
|
||||
if (!existing.resetAt || resetAt.getTime() < existing.resetAt.getTime()) {
|
||||
next = { ...existing, resetAt };
|
||||
}
|
||||
} else if (!existing.resetAt && resetAt) {
|
||||
next = { ...existing, resetAt };
|
||||
}
|
||||
if (next !== existing) {
|
||||
modelByName.set(name, next);
|
||||
}
|
||||
}
|
||||
|
||||
const parsedModels = Array.from(modelByName.values()).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const buildWindow = (label: string, remainingFraction: number, resetAt?: Date): RateWindow => ({
|
||||
label,
|
||||
usedPercent: toUsedPercent(remainingFraction),
|
||||
resetDescription: resetAt ? formatReset(resetAt) : undefined,
|
||||
resetAt: resetAt?.toISOString(),
|
||||
});
|
||||
|
||||
const windows = parsedModels.map((model) => buildWindow(model.name, model.remainingFraction, model.resetAt));
|
||||
|
||||
return this.snapshot({ windows });
|
||||
}
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
/**
|
||||
* OpenAI Codex usage provider
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
|
||||
import { formatReset, createTimeoutController } from "../../utils.js";
|
||||
import { API_TIMEOUT_MS } from "../../config.js";
|
||||
|
||||
interface CodexRateWindow {
|
||||
reset_at?: number;
|
||||
limit_window_seconds?: number;
|
||||
used_percent?: number;
|
||||
}
|
||||
|
||||
interface CodexRateLimit {
|
||||
primary_window?: CodexRateWindow;
|
||||
secondary_window?: CodexRateWindow;
|
||||
}
|
||||
|
||||
interface CodexAdditionalRateLimit {
|
||||
limit_name?: string;
|
||||
metered_feature?: string;
|
||||
rate_limit?: CodexRateLimit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Codex credentials from auth.json
|
||||
* First tries pi's auth.json, then falls back to legacy codex location
|
||||
*/
|
||||
function loadCodexCredentials(deps: Dependencies): { accessToken?: string; accountId?: string } {
|
||||
// Explicit override via env var
|
||||
const envAccessToken = (
|
||||
deps.env.OPENAI_CODEX_OAUTH_TOKEN ||
|
||||
deps.env.OPENAI_CODEX_ACCESS_TOKEN ||
|
||||
deps.env.CODEX_OAUTH_TOKEN ||
|
||||
deps.env.CODEX_ACCESS_TOKEN
|
||||
)?.trim();
|
||||
const envAccountId = (deps.env.OPENAI_CODEX_ACCOUNT_ID || deps.env.CHATGPT_ACCOUNT_ID)?.trim();
|
||||
if (envAccessToken) {
|
||||
return { accessToken: envAccessToken, accountId: envAccountId || undefined };
|
||||
}
|
||||
|
||||
// Try pi's auth.json first
|
||||
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
|
||||
try {
|
||||
if (deps.fileExists(piAuthPath)) {
|
||||
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
|
||||
if (data["openai-codex"]?.access) {
|
||||
return {
|
||||
accessToken: data["openai-codex"].access,
|
||||
accountId: data["openai-codex"].accountId,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors, try legacy location
|
||||
}
|
||||
|
||||
// Fall back to legacy codex location
|
||||
const codexHome = deps.env.CODEX_HOME || path.join(deps.homedir(), ".codex");
|
||||
const authPath = path.join(codexHome, "auth.json");
|
||||
try {
|
||||
if (deps.fileExists(authPath)) {
|
||||
const data = JSON.parse(deps.readFile(authPath) ?? "{}");
|
||||
if (data.OPENAI_API_KEY) {
|
||||
return { accessToken: data.OPENAI_API_KEY };
|
||||
} else if (data.tokens?.access_token) {
|
||||
return {
|
||||
accessToken: data.tokens.access_token,
|
||||
accountId: data.tokens.account_id,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function getWindowLabel(windowSeconds?: number, fallbackWindowSeconds?: number): string {
|
||||
const safeWindowSeconds =
|
||||
typeof windowSeconds === "number" && windowSeconds > 0
|
||||
? windowSeconds
|
||||
: typeof fallbackWindowSeconds === "number" && fallbackWindowSeconds > 0
|
||||
? fallbackWindowSeconds
|
||||
: 0;
|
||||
if (!safeWindowSeconds) {
|
||||
return "0h";
|
||||
}
|
||||
const windowHours = Math.round(safeWindowSeconds / 3600);
|
||||
if (windowHours >= 144) return "Week";
|
||||
if (windowHours >= 24) return "Day";
|
||||
return `${windowHours}h`;
|
||||
}
|
||||
|
||||
function pushWindow(
|
||||
windows: RateWindow[],
|
||||
prefix: string | undefined,
|
||||
window: CodexRateWindow | undefined,
|
||||
fallbackWindowSeconds?: number
|
||||
): void {
|
||||
if (!window) return;
|
||||
const resetDate = window.reset_at ? new Date(window.reset_at * 1000) : undefined;
|
||||
const label = getWindowLabel(window.limit_window_seconds, fallbackWindowSeconds);
|
||||
const windowLabel = prefix ? `${prefix} ${label}` : label;
|
||||
windows.push({
|
||||
label: windowLabel,
|
||||
usedPercent: window.used_percent || 0,
|
||||
resetDescription: resetDate ? formatReset(resetDate) : undefined,
|
||||
resetAt: resetDate?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
function addRateWindows(windows: RateWindow[], rateLimit: CodexRateLimit | undefined, prefix?: string): void {
|
||||
pushWindow(windows, prefix, rateLimit?.primary_window, 10800);
|
||||
pushWindow(windows, prefix, rateLimit?.secondary_window, 86400);
|
||||
}
|
||||
|
||||
export class CodexProvider extends BaseProvider {
|
||||
readonly name = "codex" as const;
|
||||
readonly displayName = "Codex Plan";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(loadCodexCredentials(deps).accessToken);
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const { accessToken, accountId } = loadCodexCredentials(deps);
|
||||
if (!accessToken) {
|
||||
return this.emptySnapshot(noCredentials());
|
||||
}
|
||||
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (accountId) {
|
||||
headers["ChatGPT-Account-Id"] = accountId;
|
||||
}
|
||||
|
||||
const res = await deps.fetch("https://chatgpt.com/backend-api/wham/usage", {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clear();
|
||||
|
||||
if (!res.ok) {
|
||||
return this.emptySnapshot(httpError(res.status));
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
rate_limit?: CodexRateLimit;
|
||||
additional_rate_limits?: CodexAdditionalRateLimit[];
|
||||
};
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
addRateWindows(windows, data.rate_limit);
|
||||
|
||||
if (Array.isArray(data.additional_rate_limits)) {
|
||||
for (const entry of data.additional_rate_limits) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const prefix =
|
||||
typeof entry.limit_name === "string" && entry.limit_name.trim().length > 0
|
||||
? entry.limit_name.trim()
|
||||
: typeof entry.metered_feature === "string" && entry.metered_feature.trim().length > 0
|
||||
? entry.metered_feature.trim()
|
||||
: "Additional";
|
||||
addRateWindows(windows, entry.rate_limit, prefix);
|
||||
}
|
||||
}
|
||||
|
||||
return this.snapshot({ windows });
|
||||
} catch {
|
||||
clear();
|
||||
return this.emptySnapshot(fetchFailed());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
/**
|
||||
* GitHub Copilot usage provider
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
|
||||
import { formatReset, createTimeoutController } from "../../utils.js";
|
||||
import { API_TIMEOUT_MS } from "../../config.js";
|
||||
|
||||
/**
|
||||
* Copilot token entries stored by legacy GitHub Copilot CLI
|
||||
*/
|
||||
type CopilotHostEntry = {
|
||||
oauth_token?: string;
|
||||
user_token?: string;
|
||||
github_token?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
const COPILOT_TOKEN_KEYS: Array<keyof CopilotHostEntry> = [
|
||||
"oauth_token",
|
||||
"user_token",
|
||||
"github_token",
|
||||
"token",
|
||||
];
|
||||
|
||||
function getTokenFromHostEntry(entry: CopilotHostEntry | undefined): string | undefined {
|
||||
if (!entry) return undefined;
|
||||
for (const key of COPILOT_TOKEN_KEYS) {
|
||||
const value = entry[key];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadLegacyCopilotToken(deps: Dependencies): string | undefined {
|
||||
const configHome = deps.env.XDG_CONFIG_HOME || path.join(deps.homedir(), ".config");
|
||||
const legacyPaths = [
|
||||
path.join(configHome, "github-copilot", "hosts.json"),
|
||||
path.join(deps.homedir(), ".github-copilot", "hosts.json"),
|
||||
];
|
||||
|
||||
for (const hostsPath of legacyPaths) {
|
||||
try {
|
||||
if (!deps.fileExists(hostsPath)) continue;
|
||||
const data = JSON.parse(deps.readFile(hostsPath) ?? "{}");
|
||||
if (!data || typeof data !== "object") continue;
|
||||
|
||||
const normalizedHosts: Record<string, CopilotHostEntry> = {};
|
||||
for (const [host, entry] of Object.entries(data as Record<string, CopilotHostEntry>)) {
|
||||
normalizedHosts[host.toLowerCase()] = entry;
|
||||
}
|
||||
|
||||
const preferredToken =
|
||||
getTokenFromHostEntry(normalizedHosts["github.com"]) ||
|
||||
getTokenFromHostEntry(normalizedHosts["api.github.com"]);
|
||||
if (preferredToken) return preferredToken;
|
||||
|
||||
for (const entry of Object.values(normalizedHosts)) {
|
||||
const token = getTokenFromHostEntry(entry);
|
||||
if (token) return token;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Copilot token from pi auth.json first, then fallback to legacy locations.
|
||||
*/
|
||||
function loadCopilotToken(deps: Dependencies): string | undefined {
|
||||
// Explicit override via env var
|
||||
const envToken = (deps.env.COPILOT_GITHUB_TOKEN || deps.env.GH_TOKEN || deps.env.GITHUB_TOKEN || deps.env.COPILOT_TOKEN)?.trim();
|
||||
if (envToken) return envToken;
|
||||
|
||||
const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
|
||||
try {
|
||||
if (deps.fileExists(authPath)) {
|
||||
const data = JSON.parse(deps.readFile(authPath) ?? "{}");
|
||||
// Prefer refresh token (GitHub access token) for GitHub API endpoints.
|
||||
const piToken = data["github-copilot"]?.refresh || data["github-copilot"]?.access;
|
||||
if (typeof piToken === "string" && piToken.length > 0) return piToken;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return loadLegacyCopilotToken(deps);
|
||||
}
|
||||
|
||||
export class CopilotProvider extends BaseProvider {
|
||||
readonly name = "copilot" as const;
|
||||
readonly displayName = "Copilot Plan";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(loadCopilotToken(deps));
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const token = loadCopilotToken(deps);
|
||||
if (!token) {
|
||||
return this.emptySnapshot(noCredentials());
|
||||
}
|
||||
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await deps.fetch("https://api.github.com/copilot_internal/user", {
|
||||
headers: {
|
||||
"Editor-Version": "vscode/1.96.2",
|
||||
"User-Agent": "GitHubCopilotChat/0.26.7",
|
||||
"X-Github-Api-Version": "2025-04-01",
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clear();
|
||||
|
||||
if (!res.ok) {
|
||||
return this.emptySnapshot(httpError(res.status));
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
quota_reset_date_utc?: string;
|
||||
quota_snapshots?: {
|
||||
premium_interactions?: {
|
||||
percent_remaining?: number;
|
||||
remaining?: number;
|
||||
entitlement?: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
const resetDate = data.quota_reset_date_utc ? new Date(data.quota_reset_date_utc) : undefined;
|
||||
const resetDesc = resetDate ? formatReset(resetDate) : undefined;
|
||||
|
||||
let requestsRemaining: number | undefined;
|
||||
let requestsEntitlement: number | undefined;
|
||||
|
||||
if (data.quota_snapshots?.premium_interactions) {
|
||||
const pi = data.quota_snapshots.premium_interactions;
|
||||
const monthUsedPercent = Math.max(0, 100 - (pi.percent_remaining || 0));
|
||||
windows.push({
|
||||
label: "Month",
|
||||
usedPercent: monthUsedPercent,
|
||||
resetDescription: resetDesc,
|
||||
resetAt: resetDate?.toISOString(),
|
||||
});
|
||||
|
||||
const remaining = pi.remaining ?? 0;
|
||||
const entitlement = pi.entitlement ?? 0;
|
||||
requestsRemaining = remaining;
|
||||
requestsEntitlement = entitlement;
|
||||
}
|
||||
|
||||
return this.snapshot({
|
||||
windows,
|
||||
requestsRemaining,
|
||||
requestsEntitlement,
|
||||
});
|
||||
} catch {
|
||||
clear();
|
||||
return this.emptySnapshot(fetchFailed());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* Google Gemini usage provider
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCredentials, fetchFailed, httpError } from "../../errors.js";
|
||||
import { createTimeoutController } from "../../utils.js";
|
||||
import { API_TIMEOUT_MS } from "../../config.js";
|
||||
|
||||
/**
|
||||
* Load Gemini access token from various sources
|
||||
*/
|
||||
function loadGeminiToken(deps: Dependencies): string | undefined {
|
||||
// Explicit override via env var
|
||||
const envToken = (
|
||||
deps.env.GOOGLE_GEMINI_CLI_OAUTH_TOKEN ||
|
||||
deps.env.GOOGLE_GEMINI_CLI_ACCESS_TOKEN ||
|
||||
deps.env.GEMINI_OAUTH_TOKEN ||
|
||||
deps.env.GOOGLE_GEMINI_OAUTH_TOKEN
|
||||
)?.trim();
|
||||
if (envToken) return envToken;
|
||||
|
||||
// Try pi auth.json first
|
||||
const piAuthPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
|
||||
try {
|
||||
if (deps.fileExists(piAuthPath)) {
|
||||
const data = JSON.parse(deps.readFile(piAuthPath) ?? "{}");
|
||||
if (data["google-gemini-cli"]?.access) return data["google-gemini-cli"].access;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// Try ~/.gemini/oauth_creds.json
|
||||
const credPath = path.join(deps.homedir(), ".gemini", "oauth_creds.json");
|
||||
try {
|
||||
if (deps.fileExists(credPath)) {
|
||||
const data = JSON.parse(deps.readFile(credPath) ?? "{}");
|
||||
if (data.access_token) return data.access_token;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class GeminiProvider extends BaseProvider {
|
||||
readonly name = "gemini" as const;
|
||||
readonly displayName = "Gemini Plan";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(loadGeminiToken(deps));
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const token = loadGeminiToken(deps);
|
||||
if (!token) {
|
||||
return this.emptySnapshot(noCredentials());
|
||||
}
|
||||
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await deps.fetch("https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
signal: controller.signal,
|
||||
});
|
||||
clear();
|
||||
|
||||
if (!res.ok) {
|
||||
return this.emptySnapshot(httpError(res.status));
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
buckets?: Array<{
|
||||
modelId?: string;
|
||||
remainingFraction?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
// Aggregate quotas by model type
|
||||
const quotas: Record<string, number> = {};
|
||||
for (const bucket of data.buckets || []) {
|
||||
const model = bucket.modelId || "unknown";
|
||||
const frac = bucket.remainingFraction ?? 1;
|
||||
if (!quotas[model] || frac < quotas[model]) {
|
||||
quotas[model] = frac;
|
||||
}
|
||||
}
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
let proMin = 1;
|
||||
let flashMin = 1;
|
||||
let hasProModel = false;
|
||||
let hasFlashModel = false;
|
||||
|
||||
for (const [model, frac] of Object.entries(quotas)) {
|
||||
if (model.toLowerCase().includes("pro")) {
|
||||
hasProModel = true;
|
||||
if (frac < proMin) proMin = frac;
|
||||
}
|
||||
if (model.toLowerCase().includes("flash")) {
|
||||
hasFlashModel = true;
|
||||
if (frac < flashMin) flashMin = frac;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasProModel) {
|
||||
windows.push({ label: "Pro", usedPercent: (1 - proMin) * 100 });
|
||||
}
|
||||
if (hasFlashModel) {
|
||||
windows.push({ label: "Flash", usedPercent: (1 - flashMin) * 100 });
|
||||
}
|
||||
|
||||
return this.snapshot({ windows });
|
||||
} catch {
|
||||
clear();
|
||||
return this.emptySnapshot(fetchFailed());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* AWS Kiro usage provider
|
||||
*/
|
||||
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCli, notLoggedIn, fetchFailed } from "../../errors.js";
|
||||
import { formatReset, stripAnsi, whichSync } from "../../utils.js";
|
||||
import { CLI_TIMEOUT_MS } from "../../config.js";
|
||||
|
||||
export class KiroProvider extends BaseProvider {
|
||||
readonly name = "kiro" as const;
|
||||
readonly displayName = "Kiro Plan";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(whichSync("kiro-cli", deps));
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const kiroBinary = whichSync("kiro-cli", deps);
|
||||
if (!kiroBinary) {
|
||||
return this.emptySnapshot(noCli("kiro-cli"));
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if logged in
|
||||
try {
|
||||
deps.execFileSync(kiroBinary, ["whoami"], {
|
||||
encoding: "utf-8",
|
||||
timeout: API_TIMEOUT_MS,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch {
|
||||
return this.emptySnapshot(notLoggedIn());
|
||||
}
|
||||
|
||||
// Get usage
|
||||
const output = deps.execFileSync(kiroBinary, ["chat", "--no-interactive", "/usage"], {
|
||||
encoding: "utf-8",
|
||||
timeout: CLI_TIMEOUT_MS,
|
||||
env: { ...deps.env, TERM: "xterm-256color" },
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const stripped = stripAnsi(output);
|
||||
const windows: RateWindow[] = [];
|
||||
|
||||
// Parse credits percentage from "████...█ X%"
|
||||
let creditsPercent = 0;
|
||||
const percentMatch = stripped.match(/█+\s*(\d+)%/);
|
||||
if (percentMatch) {
|
||||
creditsPercent = parseInt(percentMatch[1], 10);
|
||||
}
|
||||
|
||||
// Parse credits used/total from "(X.XX of Y covered in plan)"
|
||||
const creditsMatch = stripped.match(/\((\d+\.?\d*)\s+of\s+(\d+)\s+covered/);
|
||||
if (creditsMatch && !percentMatch) {
|
||||
const creditsUsed = parseFloat(creditsMatch[1]);
|
||||
const creditsTotal = parseFloat(creditsMatch[2]);
|
||||
if (creditsTotal > 0) {
|
||||
creditsPercent = (creditsUsed / creditsTotal) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse reset date from "resets on 01/01"
|
||||
let resetsAt: Date | undefined;
|
||||
const resetMatch = stripped.match(/resets on (\d{2}\/\d{2})/);
|
||||
if (resetMatch) {
|
||||
const [month, day] = resetMatch[1].split("/").map(Number);
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
resetsAt = new Date(year, month - 1, day);
|
||||
if (resetsAt < now) resetsAt.setFullYear(year + 1);
|
||||
}
|
||||
|
||||
windows.push({
|
||||
label: "Credits",
|
||||
usedPercent: creditsPercent,
|
||||
resetDescription: resetsAt ? formatReset(resetsAt) : undefined,
|
||||
resetAt: resetsAt?.toISOString(),
|
||||
});
|
||||
|
||||
return this.snapshot({ windows });
|
||||
} catch {
|
||||
return this.emptySnapshot(fetchFailed());
|
||||
}
|
||||
}
|
||||
|
||||
// Kiro doesn't have a public status page
|
||||
}
|
||||
|
||||
const API_TIMEOUT_MS = 5000;
|
||||
@@ -1,191 +0,0 @@
|
||||
/**
|
||||
* OpenCode Go usage provider
|
||||
*
|
||||
* OpenCode Go is a subscription plan ($10/mo) from the OpenCode team
|
||||
* that provides access to open coding models (GLM-5, Kimi K2.5, MiniMax M2.5)
|
||||
* through opencode.ai/zen/go/v1 endpoints.
|
||||
*
|
||||
* Credentials are discovered from:
|
||||
* 1. Config file (~/.config/opencode/opencode-go-usage.json)
|
||||
* 2. Environment variables (OPENCODE_GO_WORKSPACE_ID, OPENCODE_GO_AUTH_COOKIE)
|
||||
* 3. Environment variable (OPENCODE_API_KEY) - for API key auth
|
||||
*
|
||||
* Usage limits: rolling 5h ($12), weekly ($30), monthly ($60) — tracked in dollar value.
|
||||
*
|
||||
* Usage is fetched by scraping the HTML dashboard at opencode.ai/workspace/{id}/go
|
||||
* using the auth cookie from the browser.
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCredentials, fetchFailed, apiError } from "../../errors.js";
|
||||
import { formatReset, createTimeoutController } from "../../utils.js";
|
||||
import { API_TIMEOUT_MS } from "../../config.js";
|
||||
|
||||
interface OpenCodeGoConfig {
|
||||
workspaceId?: string;
|
||||
authCookie?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from file or environment
|
||||
*/
|
||||
function loadOpenCodeGoConfig(deps: Dependencies): OpenCodeGoConfig | undefined {
|
||||
// 1. Config file (~/.config/opencode/opencode-go-usage.json)
|
||||
const configPath = path.join(deps.homedir(), ".config", "opencode", "opencode-go-usage.json");
|
||||
try {
|
||||
if (deps.fileExists(configPath)) {
|
||||
const content = deps.readFile(configPath);
|
||||
if (content) {
|
||||
const parsed = JSON.parse(content) as OpenCodeGoConfig;
|
||||
if (parsed.workspaceId && parsed.authCookie) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
// 2. Environment variables
|
||||
const workspaceId = deps.env.OPENCODE_GO_WORKSPACE_ID?.trim();
|
||||
const authCookie = deps.env.OPENCODE_GO_AUTH_COOKIE?.trim();
|
||||
if (workspaceId && authCookie) {
|
||||
return { workspaceId, authCookie };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface UsageData {
|
||||
usagePercent: number;
|
||||
resetInSec: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch usage by scraping the HTML dashboard
|
||||
*/
|
||||
async function fetchOpenCodeGoUsage(
|
||||
workspaceId: string,
|
||||
authCookie: string,
|
||||
fetch: typeof globalThis.fetch,
|
||||
signal?: AbortSignal
|
||||
): Promise<{ rolling?: UsageData; weekly?: UsageData; monthly?: UsageData }> {
|
||||
const url = `https://opencode.ai/workspace/${encodeURIComponent(workspaceId)}/go`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0",
|
||||
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
Cookie: `auth=${authCookie}`,
|
||||
},
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
throw new Error("Authentication failed. Please refresh your auth cookie.");
|
||||
}
|
||||
throw new Error(`HTTP ${response.status}: Request failed`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
const usage: { rolling?: UsageData; weekly?: UsageData; monthly?: UsageData } = {};
|
||||
const patterns = {
|
||||
rolling: /rollingUsage:\$R\[\d+\]=(\{[^}]+\})/,
|
||||
weekly: /weeklyUsage:\$R\[\d+\]=(\{[^}]+\})/,
|
||||
monthly: /monthlyUsage:\$R\[\d+\]=(\{[^}]+\})/,
|
||||
};
|
||||
|
||||
for (const [key, pattern] of Object.entries(patterns)) {
|
||||
const match = html.match(pattern);
|
||||
if (match) {
|
||||
try {
|
||||
// Fix the JavaScript object syntax to valid JSON
|
||||
const jsonStr = match[1].replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*:)/g, '$1"$2"$3');
|
||||
usage[key as keyof typeof usage] = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return usage;
|
||||
}
|
||||
|
||||
export class OpenCodeGoProvider extends BaseProvider {
|
||||
readonly name = "opencode-go" as const;
|
||||
readonly displayName = "OpenCode Go";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(loadOpenCodeGoConfig(deps));
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const config = loadOpenCodeGoConfig(deps);
|
||||
if (!config || !config.workspaceId || !config.authCookie) {
|
||||
return this.emptySnapshot(noCredentials());
|
||||
}
|
||||
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const usage = await fetchOpenCodeGoUsage(
|
||||
config.workspaceId,
|
||||
config.authCookie,
|
||||
deps.fetch,
|
||||
controller.signal
|
||||
);
|
||||
clear();
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
|
||||
if (usage.rolling) {
|
||||
const resetDate = usage.rolling.resetInSec > 0
|
||||
? new Date(Date.now() + usage.rolling.resetInSec * 1000)
|
||||
: undefined;
|
||||
windows.push({
|
||||
label: "5h",
|
||||
usedPercent: usage.rolling.usagePercent,
|
||||
resetDescription: resetDate ? formatReset(resetDate) : undefined,
|
||||
resetAt: resetDate?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (usage.weekly) {
|
||||
const resetDate = usage.weekly.resetInSec > 0
|
||||
? new Date(Date.now() + usage.weekly.resetInSec * 1000)
|
||||
: undefined;
|
||||
windows.push({
|
||||
label: "Week",
|
||||
usedPercent: usage.weekly.usagePercent,
|
||||
resetDescription: resetDate ? formatReset(resetDate) : undefined,
|
||||
resetAt: resetDate?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (usage.monthly) {
|
||||
const resetDate = usage.monthly.resetInSec > 0
|
||||
? new Date(Date.now() + usage.monthly.resetInSec * 1000)
|
||||
: undefined;
|
||||
windows.push({
|
||||
label: "Month",
|
||||
usedPercent: usage.monthly.usagePercent,
|
||||
resetDescription: resetDate ? formatReset(resetDate) : undefined,
|
||||
resetAt: resetDate?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return this.snapshot({ windows });
|
||||
} catch (err) {
|
||||
clear();
|
||||
const error = err instanceof Error ? err.message : "Unknown error";
|
||||
if (error.includes("Authentication failed")) {
|
||||
return this.emptySnapshot(apiError(error));
|
||||
}
|
||||
return this.emptySnapshot(fetchFailed(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* z.ai usage provider
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Dependencies, RateWindow, UsageSnapshot } from "../../types.js";
|
||||
import { BaseProvider } from "../../provider.js";
|
||||
import { noCredentials, fetchFailed, httpError, apiError } from "../../errors.js";
|
||||
import { formatReset, createTimeoutController } from "../../utils.js";
|
||||
import { API_TIMEOUT_MS } from "../../config.js";
|
||||
|
||||
/**
|
||||
* Load z.ai API key from environment or auth.json
|
||||
*/
|
||||
function loadZaiApiKey(deps: Dependencies): string | undefined {
|
||||
// Try environment variable first
|
||||
if (deps.env.ZAI_API_KEY) {
|
||||
return deps.env.ZAI_API_KEY;
|
||||
}
|
||||
if (deps.env.Z_AI_API_KEY) {
|
||||
return deps.env.Z_AI_API_KEY;
|
||||
}
|
||||
|
||||
// Try pi auth.json
|
||||
const authPath = path.join(deps.homedir(), ".pi", "agent", "auth.json");
|
||||
try {
|
||||
if (deps.fileExists(authPath)) {
|
||||
const auth = JSON.parse(deps.readFile(authPath) ?? "{}");
|
||||
return auth["z-ai"]?.access || auth["z-ai"]?.key || auth["zai"]?.access || auth["zai"]?.key;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class ZaiProvider extends BaseProvider {
|
||||
readonly name = "zai" as const;
|
||||
readonly displayName = "z.ai Plan";
|
||||
|
||||
hasCredentials(deps: Dependencies): boolean {
|
||||
return Boolean(loadZaiApiKey(deps));
|
||||
}
|
||||
|
||||
async fetchUsage(deps: Dependencies): Promise<UsageSnapshot> {
|
||||
const apiKey = loadZaiApiKey(deps);
|
||||
if (!apiKey) {
|
||||
return this.emptySnapshot(noCredentials());
|
||||
}
|
||||
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await deps.fetch("https://api.z.ai/api/monitor/usage/quota/limit", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
clear();
|
||||
|
||||
if (!res.ok) {
|
||||
return this.emptySnapshot(httpError(res.status));
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
success?: boolean;
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
limits?: Array<{
|
||||
type?: string;
|
||||
unit?: number;
|
||||
number?: number;
|
||||
percentage?: number;
|
||||
nextResetTime?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
if (!data.success || data.code !== 200) {
|
||||
return this.emptySnapshot(apiError(data.msg || "API error"));
|
||||
}
|
||||
|
||||
const windows: RateWindow[] = [];
|
||||
const limits = data.data?.limits || [];
|
||||
|
||||
for (const limit of limits) {
|
||||
const percent = limit.percentage || 0;
|
||||
const nextReset = limit.nextResetTime ? new Date(limit.nextResetTime) : undefined;
|
||||
|
||||
if (limit.type === "TOKENS_LIMIT") {
|
||||
windows.push({
|
||||
label: "Tokens",
|
||||
usedPercent: percent,
|
||||
resetDescription: nextReset ? formatReset(nextReset) : undefined,
|
||||
resetAt: nextReset?.toISOString(),
|
||||
});
|
||||
} else if (limit.type === "TIME_LIMIT") {
|
||||
windows.push({
|
||||
label: "Monthly",
|
||||
usedPercent: percent,
|
||||
resetDescription: nextReset ? formatReset(nextReset) : undefined,
|
||||
resetAt: nextReset?.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.snapshot({ windows });
|
||||
} catch {
|
||||
clear();
|
||||
return this.emptySnapshot(fetchFailed());
|
||||
}
|
||||
}
|
||||
|
||||
// z.ai doesn't have a public status page
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Provider registry exports.
|
||||
*/
|
||||
|
||||
export * from "./registry.js";
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Provider metadata shared across the core.
|
||||
*/
|
||||
|
||||
export {
|
||||
PROVIDERS,
|
||||
PROVIDER_METADATA,
|
||||
PROVIDER_DISPLAY_NAMES,
|
||||
} from "../../../src/shared.js";
|
||||
|
||||
export type {
|
||||
ProviderName,
|
||||
ProviderMetadata,
|
||||
ProviderStatusConfig,
|
||||
ProviderDetectionConfig,
|
||||
} from "../../../src/shared.js";
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Provider registry - exports all providers
|
||||
*/
|
||||
|
||||
export { AnthropicProvider } from "./impl/anthropic.js";
|
||||
export { CopilotProvider } from "./impl/copilot.js";
|
||||
export { GeminiProvider } from "./impl/gemini.js";
|
||||
export { AntigravityProvider } from "./impl/antigravity.js";
|
||||
export { CodexProvider } from "./impl/codex.js";
|
||||
export { KiroProvider } from "./impl/kiro.js";
|
||||
export { ZaiProvider } from "./impl/zai.js";
|
||||
export { OpenCodeGoProvider } from "./impl/opencode-go.js";
|
||||
|
||||
import type { Dependencies, ProviderName } from "../types.js";
|
||||
import type { UsageProvider } from "../provider.js";
|
||||
import { PROVIDERS } from "./metadata.js";
|
||||
import { AnthropicProvider } from "./impl/anthropic.js";
|
||||
import { CopilotProvider } from "./impl/copilot.js";
|
||||
import { GeminiProvider } from "./impl/gemini.js";
|
||||
import { AntigravityProvider } from "./impl/antigravity.js";
|
||||
import { CodexProvider } from "./impl/codex.js";
|
||||
import { KiroProvider } from "./impl/kiro.js";
|
||||
import { ZaiProvider } from "./impl/zai.js";
|
||||
import { OpenCodeGoProvider } from "./impl/opencode-go.js";
|
||||
|
||||
const PROVIDER_FACTORIES: Record<ProviderName, () => UsageProvider> = {
|
||||
anthropic: () => new AnthropicProvider(),
|
||||
copilot: () => new CopilotProvider(),
|
||||
gemini: () => new GeminiProvider(),
|
||||
antigravity: () => new AntigravityProvider(),
|
||||
codex: () => new CodexProvider(),
|
||||
kiro: () => new KiroProvider(),
|
||||
zai: () => new ZaiProvider(),
|
||||
"opencode-go": () => new OpenCodeGoProvider(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a provider instance by name
|
||||
*/
|
||||
export function createProvider(name: ProviderName): UsageProvider {
|
||||
return PROVIDER_FACTORIES[name]();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all provider instances
|
||||
*/
|
||||
export function getAllProviders(): UsageProvider[] {
|
||||
return PROVIDERS.map((name) => PROVIDER_FACTORIES[name]());
|
||||
}
|
||||
|
||||
export function hasProviderCredentials(name: ProviderName, deps: Dependencies): boolean {
|
||||
const provider = createProvider(name);
|
||||
if (provider.hasCredentials) {
|
||||
return provider.hasCredentials(deps);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* Provider-specific settings helpers.
|
||||
*/
|
||||
|
||||
import type { SettingItem } from "@mariozechner/pi-tui";
|
||||
import type { ProviderName } from "../types.js";
|
||||
import type { Settings, CoreProviderSettings } from "../settings-types.js";
|
||||
import { CUSTOM_OPTION } from "../ui/settings-list.js";
|
||||
|
||||
function buildBaseProviderItems(ps: CoreProviderSettings): SettingItem[] {
|
||||
const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off";
|
||||
return [
|
||||
{
|
||||
id: "enabled",
|
||||
label: "Enabled",
|
||||
currentValue: enabledValue,
|
||||
values: ["auto", "on", "off"],
|
||||
description: "Auto enables if credentials are detected.",
|
||||
},
|
||||
{
|
||||
id: "fetchStatus",
|
||||
label: "Fetch Status",
|
||||
currentValue: ps.fetchStatus ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Fetch status page indicator for this provider.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function resolveEnabledValue(value: string): CoreProviderSettings["enabled"] {
|
||||
if (value === "auto") return "auto";
|
||||
return value === "on";
|
||||
}
|
||||
|
||||
function applyBaseProviderSetting(ps: CoreProviderSettings, id: string, value: string): boolean {
|
||||
switch (id) {
|
||||
case "enabled":
|
||||
ps.enabled = resolveEnabledValue(value);
|
||||
return true;
|
||||
case "fetchStatus":
|
||||
ps.fetchStatus = value === "on";
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build settings items for a specific provider.
|
||||
*/
|
||||
export function buildProviderSettingsItems(settings: Settings, provider: ProviderName): SettingItem[] {
|
||||
const ps = settings.providers[provider];
|
||||
const items = buildBaseProviderItems(ps);
|
||||
|
||||
if (provider === "anthropic") {
|
||||
const currencySymbol = ps.extraUsageCurrencySymbol?.trim();
|
||||
items.push(
|
||||
{
|
||||
id: "extraUsageCurrencySymbol",
|
||||
label: "Extra Usage Currency Symbol",
|
||||
currentValue: currencySymbol ? currencySymbol : "none",
|
||||
values: ["none", CUSTOM_OPTION],
|
||||
description: "Prefix symbol for Extra usage amounts.",
|
||||
},
|
||||
{
|
||||
id: "extraUsageDecimalSeparator",
|
||||
label: "Extra Usage Decimal Separator",
|
||||
currentValue: ps.extraUsageDecimalSeparator === "," ? "," : ".",
|
||||
values: [".", ","],
|
||||
description: "Decimal separator for Extra usage amounts.",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a provider settings change in-place.
|
||||
*/
|
||||
export function applyProviderSettingsChange(
|
||||
settings: Settings,
|
||||
provider: ProviderName,
|
||||
id: string,
|
||||
value: string
|
||||
): Settings {
|
||||
const ps = settings.providers[provider];
|
||||
if (applyBaseProviderSetting(ps, id, value)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
switch (id) {
|
||||
case "extraUsageCurrencySymbol":
|
||||
if (value === CUSTOM_OPTION) {
|
||||
return settings;
|
||||
}
|
||||
if (value === "none") {
|
||||
delete ps.extraUsageCurrencySymbol;
|
||||
return settings;
|
||||
}
|
||||
ps.extraUsageCurrencySymbol = value;
|
||||
return settings;
|
||||
case "extraUsageDecimalSeparator":
|
||||
ps.extraUsageDecimalSeparator = value === "," ? "," : ".";
|
||||
return settings;
|
||||
default:
|
||||
return settings;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* Provider status handling helpers.
|
||||
*/
|
||||
|
||||
import type { Dependencies, ProviderName, ProviderStatus } from "../types.js";
|
||||
import { fetchProviderStatus } from "../status.js";
|
||||
import { PROVIDER_METADATA } from "./metadata.js";
|
||||
|
||||
export function providerHasStatus(
|
||||
provider: ProviderName,
|
||||
providerInstance?: { fetchStatus?: (deps: Dependencies) => Promise<ProviderStatus> }
|
||||
): boolean {
|
||||
return Boolean(providerInstance?.fetchStatus) || Boolean(PROVIDER_METADATA[provider]?.status);
|
||||
}
|
||||
|
||||
export async function fetchProviderStatusWithFallback(
|
||||
provider: ProviderName,
|
||||
providerInstance: { fetchStatus?: (deps: Dependencies) => Promise<ProviderStatus> },
|
||||
deps: Dependencies
|
||||
): Promise<ProviderStatus> {
|
||||
if (providerInstance.fetchStatus) {
|
||||
return providerInstance.fetchStatus(deps);
|
||||
}
|
||||
return fetchProviderStatus(provider, deps);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Settings types and defaults for sub-core
|
||||
*/
|
||||
|
||||
import type {
|
||||
CoreSettings,
|
||||
CoreProviderSettingsMap,
|
||||
CoreProviderSettings,
|
||||
BehaviorSettings,
|
||||
ProviderName,
|
||||
ProviderEnabledSetting,
|
||||
} from "../../src/shared.js";
|
||||
import { PROVIDERS, getDefaultCoreSettings } from "../../src/shared.js";
|
||||
|
||||
export type {
|
||||
CoreProviderSettings,
|
||||
CoreProviderSettingsMap,
|
||||
BehaviorSettings,
|
||||
CoreSettings,
|
||||
ProviderEnabledSetting,
|
||||
} from "../../src/shared.js";
|
||||
|
||||
/**
|
||||
* Tool registration settings
|
||||
*/
|
||||
export interface ToolSettings {
|
||||
usageTool: boolean;
|
||||
allUsageTool: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* All settings
|
||||
*/
|
||||
export interface Settings extends CoreSettings {
|
||||
/** Version for migration */
|
||||
version: number;
|
||||
/** Tool registration settings */
|
||||
tools: ToolSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current settings version
|
||||
*/
|
||||
export const SETTINGS_VERSION = 3;
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
export function getDefaultSettings(): Settings {
|
||||
const coreDefaults = getDefaultCoreSettings();
|
||||
return {
|
||||
version: SETTINGS_VERSION,
|
||||
tools: {
|
||||
usageTool: false,
|
||||
allUsageTool: false,
|
||||
},
|
||||
providers: coreDefaults.providers,
|
||||
behavior: coreDefaults.behavior,
|
||||
statusRefresh: coreDefaults.statusRefresh,
|
||||
providerOrder: coreDefaults.providerOrder,
|
||||
defaultProvider: coreDefaults.defaultProvider,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep merge two objects
|
||||
*/
|
||||
function deepMerge<T extends object>(target: T, source: Partial<T>): T {
|
||||
const result = { ...target } as T;
|
||||
for (const key of Object.keys(source) as (keyof T)[]) {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = result[key];
|
||||
if (
|
||||
sourceValue !== undefined &&
|
||||
typeof sourceValue === "object" &&
|
||||
sourceValue !== null &&
|
||||
!Array.isArray(sourceValue) &&
|
||||
typeof targetValue === "object" &&
|
||||
targetValue !== null &&
|
||||
!Array.isArray(targetValue)
|
||||
) {
|
||||
result[key] = deepMerge(targetValue as object, sourceValue as object) as T[keyof T];
|
||||
} else if (sourceValue !== undefined) {
|
||||
result[key] = sourceValue as T[keyof T];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge settings with defaults (no legacy migrations).
|
||||
*/
|
||||
export function mergeSettings(loaded: Partial<Settings>): Settings {
|
||||
return deepMerge(getDefaultSettings(), loaded);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { showSettingsUI } from "./settings/ui.js";
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* Settings persistence for sub-core
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import type { Settings } from "./settings-types.js";
|
||||
import { getDefaultSettings, mergeSettings, SETTINGS_VERSION } from "./settings-types.js";
|
||||
import { getStorage } from "./storage.js";
|
||||
import { getLegacySettingsPath, getSettingsPath } from "./paths.js";
|
||||
import { clearCache } from "./cache.js";
|
||||
|
||||
/**
|
||||
* Settings file path
|
||||
*/
|
||||
export const SETTINGS_PATH = getSettingsPath();
|
||||
const LEGACY_SETTINGS_PATH = getLegacySettingsPath();
|
||||
|
||||
/**
|
||||
* In-memory settings cache
|
||||
*/
|
||||
let cachedSettings: Settings | undefined;
|
||||
|
||||
type LoadedSettings = {
|
||||
settings: Settings;
|
||||
loadedVersion: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensure the settings directory exists
|
||||
*/
|
||||
function ensureSettingsDir(): void {
|
||||
const storage = getStorage();
|
||||
const dir = path.dirname(SETTINGS_PATH);
|
||||
storage.ensureDir(dir);
|
||||
}
|
||||
|
||||
function loadSettingsFromDisk(settingsPath: string): LoadedSettings | null {
|
||||
const storage = getStorage();
|
||||
if (!storage.exists(settingsPath)) return null;
|
||||
const content = storage.readFile(settingsPath);
|
||||
if (!content) return null;
|
||||
const loaded = JSON.parse(content) as Partial<Settings>;
|
||||
const loadedVersion = typeof loaded.version === "number" ? loaded.version : 0;
|
||||
const merged = mergeSettings(loaded);
|
||||
return { settings: merged, loadedVersion };
|
||||
}
|
||||
|
||||
function applyVersionMigration(settings: Settings, loadedVersion: number): { settings: Settings; needsSave: boolean } {
|
||||
if (loadedVersion < SETTINGS_VERSION) {
|
||||
clearCache();
|
||||
return { settings: { ...settings, version: SETTINGS_VERSION }, needsSave: true };
|
||||
}
|
||||
return { settings, needsSave: false };
|
||||
}
|
||||
|
||||
function tryLoadSettings(settingsPath: string): LoadedSettings | null {
|
||||
try {
|
||||
return loadSettingsFromDisk(settingsPath);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load settings from ${settingsPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from disk
|
||||
*/
|
||||
export function loadSettings(): Settings {
|
||||
if (cachedSettings) {
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
const diskSettings = tryLoadSettings(SETTINGS_PATH);
|
||||
if (diskSettings) {
|
||||
const { settings: next, needsSave } = applyVersionMigration(diskSettings.settings, diskSettings.loadedVersion);
|
||||
if (needsSave) {
|
||||
saveSettings(next);
|
||||
}
|
||||
cachedSettings = next;
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
const legacySettings = tryLoadSettings(LEGACY_SETTINGS_PATH);
|
||||
if (legacySettings) {
|
||||
const { settings: next } = applyVersionMigration(legacySettings.settings, legacySettings.loadedVersion);
|
||||
const saved = saveSettings(next);
|
||||
if (saved) {
|
||||
getStorage().removeFile(LEGACY_SETTINGS_PATH);
|
||||
}
|
||||
cachedSettings = next;
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
// Return defaults if file doesn't exist or failed to load
|
||||
cachedSettings = getDefaultSettings();
|
||||
return cachedSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to disk
|
||||
*/
|
||||
export function saveSettings(settings: Settings): boolean {
|
||||
const storage = getStorage();
|
||||
try {
|
||||
ensureSettingsDir();
|
||||
const content = JSON.stringify(settings, null, 2);
|
||||
storage.writeFile(SETTINGS_PATH, content);
|
||||
cachedSettings = settings;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to save settings to ${SETTINGS_PATH}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset settings to defaults
|
||||
*/
|
||||
export function resetSettings(): Settings {
|
||||
const defaults = getDefaultSettings();
|
||||
saveSettings(defaults);
|
||||
return defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current settings (cached)
|
||||
*/
|
||||
export function getSettings(): Settings {
|
||||
return loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the settings cache (force reload on next access)
|
||||
*/
|
||||
export function clearSettingsCache(): void {
|
||||
cachedSettings = undefined;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Behavior settings UI helpers.
|
||||
*/
|
||||
|
||||
import type { SettingItem } from "@mariozechner/pi-tui";
|
||||
import type { BehaviorSettings } from "../settings-types.js";
|
||||
import { CUSTOM_OPTION } from "../ui/settings-list.js";
|
||||
|
||||
export function buildRefreshItems(settings: BehaviorSettings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "refreshInterval",
|
||||
label: "Auto-refresh Interval",
|
||||
currentValue: settings.refreshInterval === 0 ? "off" : `${settings.refreshInterval}s`,
|
||||
values: ["off", "15s", "30s", "60s", "120s", "300s", CUSTOM_OPTION],
|
||||
description: "How often to refresh automatically.",
|
||||
},
|
||||
{
|
||||
id: "minRefreshInterval",
|
||||
label: "Minimum Refresh Interval",
|
||||
currentValue: settings.minRefreshInterval === 0 ? "off" : `${settings.minRefreshInterval}s`,
|
||||
values: ["off", "5s", "10s", "15s", "30s", "60s", "120s", CUSTOM_OPTION],
|
||||
description: "Cap refreshes even when triggered each turn.",
|
||||
},
|
||||
{
|
||||
id: "refreshOnTurnStart",
|
||||
label: "Refresh on Turn Start",
|
||||
currentValue: settings.refreshOnTurnStart ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Refresh when a new turn starts.",
|
||||
},
|
||||
{
|
||||
id: "refreshOnToolResult",
|
||||
label: "Refresh on Tool Result",
|
||||
currentValue: settings.refreshOnToolResult ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Refresh after tool executions.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function applyRefreshChange(settings: BehaviorSettings, id: string, value: string): BehaviorSettings {
|
||||
switch (id) {
|
||||
case "refreshInterval":
|
||||
settings.refreshInterval = value === "off" ? 0 : parseInt(value, 10);
|
||||
break;
|
||||
case "minRefreshInterval":
|
||||
settings.minRefreshInterval = value === "off" ? 0 : parseInt(value, 10);
|
||||
break;
|
||||
case "refreshOnTurnStart":
|
||||
settings.refreshOnTurnStart = value === "on";
|
||||
break;
|
||||
case "refreshOnToolResult":
|
||||
settings.refreshOnToolResult = value === "on";
|
||||
break;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Settings menu item builders.
|
||||
*/
|
||||
|
||||
import type { SelectItem } from "@mariozechner/pi-tui";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import type { ProviderName } from "../types.js";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
|
||||
|
||||
export type TooltipSelectItem = SelectItem & { tooltip?: string };
|
||||
|
||||
export function buildMainMenuItems(settings: Settings): TooltipSelectItem[] {
|
||||
const enabledCount = Object.values(settings.providers).filter((p) => p.enabled !== "off" && p.enabled !== false).length;
|
||||
const totalCount = Object.keys(settings.providers).length;
|
||||
const toolEnabledCount = [settings.tools.usageTool, settings.tools.allUsageTool].filter(Boolean).length;
|
||||
const toolTotalCount = 2;
|
||||
|
||||
return [
|
||||
{
|
||||
value: "providers",
|
||||
label: "Provider Settings",
|
||||
description: `${enabledCount}/${totalCount} enabled`,
|
||||
tooltip: "Enable providers, toggle status fetch, and adjust provider settings.",
|
||||
},
|
||||
{
|
||||
value: "behavior",
|
||||
label: "Usage Refresh Settings",
|
||||
description: `refresh ${settings.behavior.refreshInterval}s`,
|
||||
tooltip: "Control usage refresh interval and triggers.",
|
||||
},
|
||||
{
|
||||
value: "status-refresh",
|
||||
label: "Status Refresh Settings",
|
||||
description: `refresh ${settings.statusRefresh.refreshInterval}s`,
|
||||
tooltip: "Control status refresh interval and triggers.",
|
||||
},
|
||||
{
|
||||
value: "tools",
|
||||
label: "Tool Settings",
|
||||
description: `${toolEnabledCount}/${toolTotalCount} enabled`,
|
||||
tooltip: "Enable sub-core tools (requires /reload to take effect).",
|
||||
},
|
||||
{
|
||||
value: "provider-order",
|
||||
label: "Provider Order",
|
||||
description: settings.providerOrder.slice(0, 3).join(", ") + "...",
|
||||
tooltip: "Reorder providers for cycling and auto-selection.",
|
||||
},
|
||||
{
|
||||
value: "reset",
|
||||
label: "Reset to Defaults",
|
||||
description: "restore all settings",
|
||||
tooltip: "Restore all sub-core settings to defaults.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildProviderListItems(settings: Settings): TooltipSelectItem[] {
|
||||
return settings.providerOrder.map((provider) => {
|
||||
const ps = settings.providers[provider];
|
||||
const enabledValue = ps.enabled === "auto" ? "auto" : ps.enabled === true || ps.enabled === "on" ? "on" : "off";
|
||||
const statusIcon = ps.fetchStatus ? ", status fetch on" : "";
|
||||
return {
|
||||
value: `provider-${provider}`,
|
||||
label: PROVIDER_DISPLAY_NAMES[provider],
|
||||
description: `enabled ${enabledValue}${statusIcon}`,
|
||||
tooltip: `Enable ${PROVIDER_DISPLAY_NAMES[provider]} and configure status fetching.`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildProviderOrderItems(settings: Settings): TooltipSelectItem[] {
|
||||
const activeProviders = settings.providerOrder.filter((provider) => {
|
||||
const enabled = settings.providers[provider].enabled;
|
||||
return enabled !== "off" && enabled !== false;
|
||||
});
|
||||
return activeProviders.map((provider, index) => ({
|
||||
value: provider,
|
||||
label: `${index + 1}. ${PROVIDER_DISPLAY_NAMES[provider]}`,
|
||||
tooltip: "Reorder enabled providers (Space to toggle move mode).",
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Tool settings UI helpers.
|
||||
*/
|
||||
|
||||
import type { SettingItem } from "@mariozechner/pi-tui";
|
||||
import type { Settings, ToolSettings } from "../settings-types.js";
|
||||
|
||||
export function buildToolItems(settings: ToolSettings): SettingItem[] {
|
||||
return [
|
||||
{
|
||||
id: "usageTool",
|
||||
label: "Usage Tool",
|
||||
currentValue: settings.usageTool ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Expose sub_get_usage/get_current_usage (requires /reload).",
|
||||
},
|
||||
{
|
||||
id: "allUsageTool",
|
||||
label: "All Usage Tool",
|
||||
currentValue: settings.allUsageTool ? "on" : "off",
|
||||
values: ["on", "off"],
|
||||
description: "Expose sub_get_all_usage/get_all_usage (requires /reload).",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function applyToolChange(settings: Settings, id: string, value: string): Settings {
|
||||
const enabled = value === "on";
|
||||
switch (id) {
|
||||
case "usageTool":
|
||||
settings.tools.usageTool = enabled;
|
||||
break;
|
||||
case "allUsageTool":
|
||||
settings.tools.allUsageTool = enabled;
|
||||
break;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* Settings UI for sub-core
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder, getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Input, type SelectItem, SelectList, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { SettingsList, type SettingItem, CUSTOM_OPTION } from "../ui/settings-list.js";
|
||||
import type { ProviderName } from "../types.js";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import { getDefaultSettings } from "../settings-types.js";
|
||||
import { getSettings, saveSettings, resetSettings } from "../settings.js";
|
||||
import { PROVIDER_DISPLAY_NAMES } from "../providers/metadata.js";
|
||||
import { buildProviderSettingsItems, applyProviderSettingsChange } from "../providers/settings.js";
|
||||
import { buildRefreshItems, applyRefreshChange } from "./behavior.js";
|
||||
import { buildToolItems, applyToolChange } from "./tools.js";
|
||||
import { buildMainMenuItems, buildProviderListItems, buildProviderOrderItems, type TooltipSelectItem } from "./menu.js";
|
||||
|
||||
/**
|
||||
* Settings category
|
||||
*/
|
||||
type ProviderCategory = `provider-${ProviderName}`;
|
||||
|
||||
type SettingsCategory =
|
||||
| "main"
|
||||
| "providers"
|
||||
| ProviderCategory
|
||||
| "behavior"
|
||||
| "status-refresh"
|
||||
| "tools"
|
||||
| "provider-order";
|
||||
|
||||
/**
|
||||
* Extract provider name from category
|
||||
*/
|
||||
function getProviderFromCategory(category: SettingsCategory): ProviderName | null {
|
||||
const match = category.match(/^provider-(\w+)$/);
|
||||
if (match && match[1] !== "order") {
|
||||
return match[1] as ProviderName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the settings UI
|
||||
*/
|
||||
export async function showSettingsUI(
|
||||
ctx: ExtensionContext,
|
||||
onSettingsChange?: (settings: Settings) => void | Promise<void>
|
||||
): Promise<Settings> {
|
||||
let settings = getSettings();
|
||||
let currentCategory: SettingsCategory = "main";
|
||||
let providerOrderSelectedIndex = 0;
|
||||
let providerOrderReordering = false;
|
||||
let suppressProviderOrderChange = false;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
ctx.ui.custom<Settings>((tui, theme, _kb, done) => {
|
||||
let container = new Container();
|
||||
let activeList: SelectList | SettingsList | null = null;
|
||||
const clamp = (value: number, min: number, max: number): number => Math.min(max, Math.max(min, value));
|
||||
|
||||
const buildInputSubmenu = (
|
||||
label: string,
|
||||
parseValue: (value: string) => string | null,
|
||||
formatInitial?: (value: string) => string,
|
||||
) => {
|
||||
return (currentValue: string, done: (selectedValue?: string) => void) => {
|
||||
const input = new Input();
|
||||
input.focused = true;
|
||||
input.setValue(formatInitial ? formatInitial("") : "");
|
||||
input.onSubmit = (value) => {
|
||||
const parsed = parseValue(value);
|
||||
if (!parsed) return;
|
||||
done(parsed);
|
||||
};
|
||||
input.onEscape = () => {
|
||||
done();
|
||||
};
|
||||
|
||||
const inputContainer = new Container();
|
||||
inputContainer.addChild(new Text(theme.fg("muted", label), 1, 0));
|
||||
inputContainer.addChild(new Spacer(1));
|
||||
inputContainer.addChild(input);
|
||||
|
||||
return {
|
||||
render: (width: number) => inputContainer.render(width),
|
||||
invalidate: () => inputContainer.invalidate(),
|
||||
handleInput: (data: string) => input.handleInput(data),
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const parseRefreshInterval = (raw: string): string | null => {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
ctx.ui.notify("Enter a value", "warning");
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "off") return "off";
|
||||
const cleaned = trimmed.replace(/s$/, "");
|
||||
const parsed = Number.parseInt(cleaned, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
ctx.ui.notify("Enter seconds", "warning");
|
||||
return null;
|
||||
}
|
||||
const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
|
||||
return clamped === 0 ? "off" : `${clamped}s`;
|
||||
};
|
||||
|
||||
const parseMinRefreshInterval = (raw: string): string | null => {
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
if (!trimmed) {
|
||||
ctx.ui.notify("Enter a value", "warning");
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "off") return "off";
|
||||
const cleaned = trimmed.replace(/s$/, "");
|
||||
const parsed = Number.parseInt(cleaned, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
ctx.ui.notify("Enter seconds", "warning");
|
||||
return null;
|
||||
}
|
||||
const clamped = parsed <= 0 ? 0 : clamp(parsed, 5, 3600);
|
||||
return clamped === 0 ? "off" : `${clamped}s`;
|
||||
};
|
||||
|
||||
const parseCurrencySymbol = (raw: string): string | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
ctx.ui.notify("Enter a symbol or 'none'", "warning");
|
||||
return null;
|
||||
}
|
||||
if (trimmed.toLowerCase() === "none") return "none";
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
function rebuild(): void {
|
||||
container = new Container();
|
||||
let tooltipText: Text | null = null;
|
||||
|
||||
const attachTooltip = (items: TooltipSelectItem[], selectList: SelectList): void => {
|
||||
if (!items.some((item) => item.tooltip)) return;
|
||||
const tooltipComponent = new Text("", 1, 0);
|
||||
const setTooltip = (item?: TooltipSelectItem | null) => {
|
||||
const tooltip = item?.tooltip?.trim();
|
||||
tooltipComponent.setText(tooltip ? theme.fg("dim", tooltip) : "");
|
||||
};
|
||||
setTooltip(selectList.getSelectedItem() as TooltipSelectItem | null);
|
||||
const existingHandler = selectList.onSelectionChange;
|
||||
selectList.onSelectionChange = (item) => {
|
||||
if (existingHandler) existingHandler(item);
|
||||
setTooltip(item as TooltipSelectItem);
|
||||
tui.requestRender();
|
||||
};
|
||||
tooltipText = tooltipComponent;
|
||||
};
|
||||
|
||||
// Top border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
|
||||
// Title
|
||||
const titles: Record<string, string> = {
|
||||
main: "sub-core Settings",
|
||||
providers: "Provider Settings",
|
||||
behavior: "Usage Refresh Settings",
|
||||
"status-refresh": "Status Refresh Settings",
|
||||
tools: "Tool Settings",
|
||||
"provider-order": "Provider Order",
|
||||
};
|
||||
const providerCategory = getProviderFromCategory(currentCategory);
|
||||
const title = providerCategory
|
||||
? `${PROVIDER_DISPLAY_NAMES[providerCategory]} Settings`
|
||||
: titles[currentCategory] ?? "sub-core Settings";
|
||||
container.addChild(new Text(theme.fg("accent", theme.bold(title)), 1, 0));
|
||||
container.addChild(new Spacer(1));
|
||||
|
||||
if (currentCategory === "main") {
|
||||
const items = buildMainMenuItems(settings);
|
||||
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
||||
selectedPrefix: (t: string) => theme.fg("accent", t),
|
||||
selectedText: (t: string) => theme.fg("accent", t),
|
||||
description: (t: string) => theme.fg("muted", t),
|
||||
scrollInfo: (t: string) => theme.fg("dim", t),
|
||||
noMatch: (t: string) => theme.fg("warning", t),
|
||||
});
|
||||
attachTooltip(items, selectList);
|
||||
selectList.onSelect = (item) => {
|
||||
if (item.value === "reset") {
|
||||
settings = resetSettings();
|
||||
if (onSettingsChange) void onSettingsChange(settings);
|
||||
ctx.ui.notify("Settings reset to defaults", "info");
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
} else {
|
||||
currentCategory = item.value as SettingsCategory;
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
}
|
||||
};
|
||||
selectList.onCancel = () => {
|
||||
saveSettings(settings);
|
||||
done(settings);
|
||||
};
|
||||
activeList = selectList;
|
||||
container.addChild(selectList);
|
||||
} else if (currentCategory === "providers") {
|
||||
const items = buildProviderListItems(settings);
|
||||
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
||||
selectedPrefix: (t: string) => theme.fg("accent", t),
|
||||
selectedText: (t: string) => theme.fg("accent", t),
|
||||
description: (t: string) => theme.fg("muted", t),
|
||||
scrollInfo: (t: string) => theme.fg("dim", t),
|
||||
noMatch: (t: string) => theme.fg("warning", t),
|
||||
});
|
||||
attachTooltip(items, selectList);
|
||||
selectList.onSelect = (item) => {
|
||||
currentCategory = item.value as SettingsCategory;
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
};
|
||||
selectList.onCancel = () => {
|
||||
currentCategory = "main";
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
};
|
||||
activeList = selectList;
|
||||
container.addChild(selectList);
|
||||
} else if (currentCategory === "provider-order") {
|
||||
const items = buildProviderOrderItems(settings);
|
||||
const isReordering = providerOrderReordering;
|
||||
const selectList = new SelectList(items, Math.min(items.length, 10), {
|
||||
selectedPrefix: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
|
||||
selectedText: (t: string) => isReordering ? theme.fg("warning", t) : theme.fg("accent", t),
|
||||
description: (t: string) => theme.fg("muted", t),
|
||||
scrollInfo: (t: string) => theme.fg("dim", t),
|
||||
noMatch: (t: string) => theme.fg("warning", t),
|
||||
});
|
||||
|
||||
if (items.length > 0) {
|
||||
suppressProviderOrderChange = true;
|
||||
providerOrderSelectedIndex = Math.min(providerOrderSelectedIndex, items.length - 1);
|
||||
selectList.setSelectedIndex(providerOrderSelectedIndex);
|
||||
suppressProviderOrderChange = false;
|
||||
}
|
||||
|
||||
selectList.onSelectionChange = (item) => {
|
||||
if (suppressProviderOrderChange) return;
|
||||
|
||||
const newIndex = items.findIndex((listItem) => listItem.value === item.value);
|
||||
if (newIndex === -1) return;
|
||||
|
||||
if (!providerOrderReordering) {
|
||||
providerOrderSelectedIndex = newIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeProviders = settings.providerOrder.filter((provider) => {
|
||||
const enabled = settings.providers[provider].enabled;
|
||||
return enabled !== "off" && enabled !== false;
|
||||
});
|
||||
const oldIndex = providerOrderSelectedIndex;
|
||||
if (newIndex === oldIndex) return;
|
||||
if (oldIndex < 0 || oldIndex >= activeProviders.length) return;
|
||||
|
||||
const provider = activeProviders[oldIndex];
|
||||
const updatedActive = [...activeProviders];
|
||||
updatedActive.splice(oldIndex, 1);
|
||||
updatedActive.splice(newIndex, 0, provider);
|
||||
|
||||
let activeIndex = 0;
|
||||
settings.providerOrder = settings.providerOrder.map((existing) => {
|
||||
const enabled = settings.providers[existing].enabled;
|
||||
if (enabled === "off" || enabled === false) return existing;
|
||||
const next = updatedActive[activeIndex];
|
||||
activeIndex += 1;
|
||||
return next;
|
||||
});
|
||||
|
||||
providerOrderSelectedIndex = newIndex;
|
||||
saveSettings(settings);
|
||||
if (onSettingsChange) void onSettingsChange(settings);
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
attachTooltip(items, selectList);
|
||||
|
||||
selectList.onSelect = () => {
|
||||
if (items.length === 0) return;
|
||||
providerOrderReordering = !providerOrderReordering;
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
selectList.onCancel = () => {
|
||||
if (providerOrderReordering) {
|
||||
providerOrderReordering = false;
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
currentCategory = "main";
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
};
|
||||
|
||||
activeList = selectList;
|
||||
container.addChild(selectList);
|
||||
} else {
|
||||
let items: SettingItem[];
|
||||
let handleChange: (id: string, value: string) => void;
|
||||
let backCategory: SettingsCategory = "main";
|
||||
|
||||
const provider = getProviderFromCategory(currentCategory);
|
||||
if (provider) {
|
||||
items = buildProviderSettingsItems(settings, provider);
|
||||
const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {};
|
||||
if (provider === "anthropic") {
|
||||
customHandlers.extraUsageCurrencySymbol = buildInputSubmenu(
|
||||
"Extra Usage Currency Symbol",
|
||||
parseCurrencySymbol,
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
for (const item of items) {
|
||||
if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
|
||||
item.submenu = customHandlers[item.id];
|
||||
}
|
||||
}
|
||||
handleChange = (id, value) => {
|
||||
settings = applyProviderSettingsChange(settings, provider, id, value);
|
||||
saveSettings(settings);
|
||||
if (onSettingsChange) void onSettingsChange(settings);
|
||||
};
|
||||
backCategory = "providers";
|
||||
} else if (currentCategory === "tools") {
|
||||
items = buildToolItems(settings.tools);
|
||||
handleChange = (id, value) => {
|
||||
settings = applyToolChange(settings, id, value);
|
||||
saveSettings(settings);
|
||||
if (onSettingsChange) void onSettingsChange(settings);
|
||||
};
|
||||
backCategory = "main";
|
||||
} else {
|
||||
const refreshTarget = currentCategory === "status-refresh" ? settings.statusRefresh : settings.behavior;
|
||||
items = buildRefreshItems(refreshTarget);
|
||||
const customHandlers: Record<string, ReturnType<typeof buildInputSubmenu>> = {
|
||||
refreshInterval: buildInputSubmenu("Auto-refresh Interval (seconds)", parseRefreshInterval),
|
||||
minRefreshInterval: buildInputSubmenu("Minimum Refresh Interval (seconds)", parseMinRefreshInterval),
|
||||
};
|
||||
for (const item of items) {
|
||||
if (item.values?.includes(CUSTOM_OPTION) && customHandlers[item.id]) {
|
||||
item.submenu = customHandlers[item.id];
|
||||
}
|
||||
}
|
||||
handleChange = (id, value) => {
|
||||
applyRefreshChange(refreshTarget, id, value);
|
||||
saveSettings(settings);
|
||||
if (onSettingsChange) void onSettingsChange(settings);
|
||||
};
|
||||
backCategory = "main";
|
||||
}
|
||||
|
||||
const settingsHintText = "↓ navigate • ←/→ change • Enter/Space edit custom • Esc to cancel";
|
||||
const customTheme = {
|
||||
...getSettingsListTheme(),
|
||||
hint: (text: string) => {
|
||||
if (text.includes("Enter/Space")) {
|
||||
return theme.fg("dim", settingsHintText);
|
||||
}
|
||||
return theme.fg("dim", text);
|
||||
},
|
||||
};
|
||||
const settingsList = new SettingsList(
|
||||
items,
|
||||
Math.min(items.length + 2, 15),
|
||||
customTheme,
|
||||
handleChange,
|
||||
() => {
|
||||
currentCategory = backCategory;
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
}
|
||||
);
|
||||
activeList = settingsList;
|
||||
container.addChild(settingsList);
|
||||
}
|
||||
|
||||
const usesSettingsList =
|
||||
currentCategory === "behavior" ||
|
||||
currentCategory === "status-refresh" ||
|
||||
currentCategory === "tools" ||
|
||||
getProviderFromCategory(currentCategory) !== null;
|
||||
if (!usesSettingsList) {
|
||||
let helpText: string;
|
||||
if (currentCategory === "main" || currentCategory === "providers") {
|
||||
helpText = "↑↓ navigate • Enter/Space select • Esc back";
|
||||
} else if (currentCategory === "provider-order") {
|
||||
helpText = providerOrderReordering
|
||||
? "↑↓ move provider • Esc back"
|
||||
: "↑↓ navigate • Enter/Space select • Esc back";
|
||||
} else {
|
||||
helpText = "↑↓ navigate • Enter/Space to change • Esc to cancel";
|
||||
}
|
||||
if (tooltipText) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(tooltipText);
|
||||
}
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", helpText), 1, 0));
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
||||
}
|
||||
|
||||
|
||||
rebuild();
|
||||
|
||||
return {
|
||||
render(width: number) {
|
||||
return container.render(width);
|
||||
},
|
||||
invalidate() {
|
||||
container.invalidate();
|
||||
},
|
||||
handleInput(data: string) {
|
||||
if (data === " ") {
|
||||
if (currentCategory === "provider-order") {
|
||||
providerOrderReordering = !providerOrderReordering;
|
||||
rebuild();
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (activeList && "handleInput" in activeList && activeList.handleInput) {
|
||||
activeList.handleInput("\r");
|
||||
}
|
||||
tui.requestRender();
|
||||
return;
|
||||
}
|
||||
if (activeList && "handleInput" in activeList && activeList.handleInput) {
|
||||
activeList.handleInput(data);
|
||||
}
|
||||
tui.requestRender();
|
||||
},
|
||||
};
|
||||
}).then(resolve);
|
||||
});
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
/**
|
||||
* Status polling for providers
|
||||
*/
|
||||
|
||||
import type { Dependencies, ProviderName, ProviderStatus, StatusIndicator } from "./types.js";
|
||||
import type { ProviderStatusConfig } from "./providers/metadata.js";
|
||||
import { GOOGLE_STATUS_URL, GEMINI_PRODUCT_ID, API_TIMEOUT_MS } from "./config.js";
|
||||
import { PROVIDER_METADATA } from "./providers/metadata.js";
|
||||
import { createTimeoutController } from "./utils.js";
|
||||
|
||||
type StatusPageStatusConfig = Extract<ProviderStatusConfig, { type: "statuspage" }>;
|
||||
|
||||
interface StatusPageSummary {
|
||||
status?: {
|
||||
indicator?: string;
|
||||
description?: string;
|
||||
};
|
||||
components?: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface StatusPageStatus {
|
||||
indicator?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface StatusPageResponse {
|
||||
status?: StatusPageStatus;
|
||||
}
|
||||
|
||||
function toSummaryUrl(url: string): string {
|
||||
if (url.endsWith("/summary.json")) return url;
|
||||
if (url.endsWith("/status.json")) {
|
||||
return `${url.slice(0, -"/status.json".length)}/summary.json`;
|
||||
}
|
||||
if (!url.endsWith("/")) return `${url}/summary.json`;
|
||||
return `${url}summary.json`;
|
||||
}
|
||||
|
||||
function isComponentMatch(component: { id?: string; name?: string }, config?: StatusPageStatusConfig): boolean {
|
||||
if (!config?.component) return false;
|
||||
|
||||
if (config.component.id && component.id) {
|
||||
return component.id === config.component.id;
|
||||
}
|
||||
|
||||
if (config.component.name && component.name) {
|
||||
return component.name.trim().toLowerCase() === config.component.name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function mapStatusIndicator(indicator?: string): StatusIndicator {
|
||||
switch (indicator) {
|
||||
case "none":
|
||||
return "none";
|
||||
case "minor":
|
||||
return "minor";
|
||||
case "major":
|
||||
return "major";
|
||||
case "critical":
|
||||
return "critical";
|
||||
case "maintenance":
|
||||
return "maintenance";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function mapComponentStatus(indicator?: string): StatusIndicator {
|
||||
switch ((indicator || "").toLowerCase()) {
|
||||
case "operational":
|
||||
return "none";
|
||||
case "under_maintenance":
|
||||
return "maintenance";
|
||||
case "degraded_performance":
|
||||
return "minor";
|
||||
case "partial_outage":
|
||||
return "major";
|
||||
case "major_outage":
|
||||
return "critical";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function formatComponentLabel(rawStatus?: string): string {
|
||||
switch ((rawStatus || "").toLowerCase()) {
|
||||
case "operational":
|
||||
return "Operational";
|
||||
case "under_maintenance":
|
||||
return "Under maintenance";
|
||||
case "degraded_performance":
|
||||
return "Degraded performance";
|
||||
case "partial_outage":
|
||||
return "Partial outage";
|
||||
case "major_outage":
|
||||
return "Major outage";
|
||||
default:
|
||||
return rawStatus ? rawStatus.replace(/_/g, " ") : "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch status from a standard statuspage.io API
|
||||
*/
|
||||
async function fetchStatuspageStatus(
|
||||
url: string,
|
||||
deps: Dependencies,
|
||||
config?: StatusPageStatusConfig
|
||||
): Promise<ProviderStatus> {
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const fetchUrl = config?.component ? toSummaryUrl(url) : url;
|
||||
const res = await deps.fetch(fetchUrl, { signal: controller.signal });
|
||||
clear();
|
||||
|
||||
if (!res.ok) {
|
||||
return { indicator: "unknown" };
|
||||
}
|
||||
|
||||
if (!config?.component) {
|
||||
const data = (await res.json()) as StatusPageResponse;
|
||||
const indicator = mapStatusIndicator(data.status?.indicator);
|
||||
return { indicator, description: data.status?.description };
|
||||
}
|
||||
|
||||
const data = (await res.json()) as StatusPageSummary;
|
||||
const summaryIndicator = mapStatusIndicator(data.status?.indicator);
|
||||
const component = (data.components ?? []).find((entry) => isComponentMatch(entry, config));
|
||||
if (component) {
|
||||
const componentIndicator = mapComponentStatus(component.status);
|
||||
const componentDescription =
|
||||
componentIndicator === "none"
|
||||
? undefined
|
||||
: `${component.name ?? "Component"}: ${formatComponentLabel(component.status)}`;
|
||||
return {
|
||||
indicator: componentIndicator,
|
||||
description: componentDescription,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
indicator: summaryIndicator,
|
||||
description: data.status?.description,
|
||||
};
|
||||
} catch {
|
||||
clear();
|
||||
return { indicator: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Gemini status from Google Workspace status API
|
||||
*/
|
||||
async function fetchGeminiStatus(deps: Dependencies): Promise<ProviderStatus> {
|
||||
const { controller, clear } = createTimeoutController(API_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await deps.fetch(GOOGLE_STATUS_URL, { signal: controller.signal });
|
||||
clear();
|
||||
|
||||
if (!res.ok) return { indicator: "unknown" };
|
||||
|
||||
const incidents = (await res.json()) as Array<{
|
||||
end?: string;
|
||||
currently_affected_products?: Array<{ id: string }>;
|
||||
affected_products?: Array<{ id: string }>;
|
||||
most_recent_update?: { status?: string };
|
||||
status_impact?: string;
|
||||
external_desc?: string;
|
||||
}>;
|
||||
|
||||
const activeIncidents = incidents.filter((inc) => {
|
||||
if (inc.end) return false;
|
||||
const affected = inc.currently_affected_products || inc.affected_products || [];
|
||||
return affected.some((p) => p.id === GEMINI_PRODUCT_ID);
|
||||
});
|
||||
|
||||
if (activeIncidents.length === 0) {
|
||||
return { indicator: "none" };
|
||||
}
|
||||
|
||||
let worstIndicator: StatusIndicator = "minor";
|
||||
let description: string | undefined;
|
||||
|
||||
for (const inc of activeIncidents) {
|
||||
const status = inc.most_recent_update?.status || inc.status_impact;
|
||||
if (status === "SERVICE_OUTAGE") {
|
||||
worstIndicator = "critical";
|
||||
description = inc.external_desc;
|
||||
} else if (status === "SERVICE_DISRUPTION" && worstIndicator !== "critical") {
|
||||
worstIndicator = "major";
|
||||
description = inc.external_desc;
|
||||
}
|
||||
}
|
||||
|
||||
return { indicator: worstIndicator, description };
|
||||
} catch {
|
||||
clear();
|
||||
return { indicator: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch status for a provider
|
||||
*/
|
||||
export async function fetchProviderStatus(provider: ProviderName, deps: Dependencies): Promise<ProviderStatus> {
|
||||
const statusConfig = PROVIDER_METADATA[provider]?.status;
|
||||
if (!statusConfig) {
|
||||
return { indicator: "none" };
|
||||
}
|
||||
|
||||
if (statusConfig.type === "google-workspace") {
|
||||
return fetchGeminiStatus(deps);
|
||||
}
|
||||
|
||||
return fetchStatuspageStatus(statusConfig.url, deps, statusConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get emoji for a status indicator
|
||||
*/
|
||||
export function getStatusEmoji(status?: ProviderStatus): string {
|
||||
if (!status) return "";
|
||||
switch (status.indicator) {
|
||||
case "none":
|
||||
return "✅";
|
||||
case "minor":
|
||||
return "⚠️";
|
||||
case "major":
|
||||
return "🟠";
|
||||
case "critical":
|
||||
return "🔴";
|
||||
case "maintenance":
|
||||
return "🔧";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
/**
|
||||
* Storage abstraction for settings and cache persistence.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export interface StorageAdapter {
|
||||
readFile(path: string): string | undefined;
|
||||
writeFile(path: string, contents: string): void;
|
||||
writeFileExclusive(path: string, contents: string): boolean;
|
||||
exists(path: string): boolean;
|
||||
removeFile(path: string): void;
|
||||
ensureDir(path: string): void;
|
||||
}
|
||||
|
||||
export function createFsStorage(): StorageAdapter {
|
||||
return {
|
||||
readFile(filePath: string): string | undefined {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
writeFile(filePath: string, contents: string): void {
|
||||
fs.writeFileSync(filePath, contents, "utf-8");
|
||||
},
|
||||
writeFileExclusive(filePath: string, contents: string): boolean {
|
||||
try {
|
||||
fs.writeFileSync(filePath, contents, { flag: "wx" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
exists(filePath: string): boolean {
|
||||
return fs.existsSync(filePath);
|
||||
},
|
||||
removeFile(filePath: string): void {
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
} catch {
|
||||
// Ignore remove errors
|
||||
}
|
||||
},
|
||||
ensureDir(dirPath: string): void {
|
||||
fs.mkdirSync(path.resolve(dirPath), { recursive: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let activeStorage: StorageAdapter = createFsStorage();
|
||||
|
||||
export function getStorage(): StorageAdapter {
|
||||
return activeStorage;
|
||||
}
|
||||
|
||||
export function setStorage(storage: StorageAdapter): void {
|
||||
activeStorage = storage;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* File lock helpers for storage-backed locks.
|
||||
*/
|
||||
|
||||
import { getStorage } from "../storage.js";
|
||||
|
||||
export function tryAcquireFileLock(lockPath: string, staleAfterMs: number): boolean {
|
||||
const storage = getStorage();
|
||||
try {
|
||||
if (storage.writeFileExclusive(lockPath, String(Date.now()))) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
if (storage.exists(lockPath)) {
|
||||
const lockContent = storage.readFile(lockPath) ?? "";
|
||||
const lockTime = parseInt(lockContent, 10);
|
||||
if (Date.now() - lockTime > staleAfterMs) {
|
||||
storage.writeFile(lockPath, String(Date.now()));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore, lock is held by another process
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function releaseFileLock(lockPath: string): void {
|
||||
const storage = getStorage();
|
||||
try {
|
||||
if (storage.exists(lockPath)) {
|
||||
storage.removeFile(lockPath);
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForLockRelease(
|
||||
lockPath: string,
|
||||
maxWaitMs: number,
|
||||
pollMs: number = 100
|
||||
): Promise<boolean> {
|
||||
const storage = getStorage();
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
if (!storage.exists(lockPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Core types for the sub-bar extension
|
||||
*/
|
||||
|
||||
import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
|
||||
|
||||
export type {
|
||||
ProviderName,
|
||||
StatusIndicator,
|
||||
ProviderStatus,
|
||||
RateWindow,
|
||||
UsageSnapshot,
|
||||
UsageError,
|
||||
UsageErrorCode,
|
||||
ProviderUsageEntry,
|
||||
SubCoreState,
|
||||
SubCoreEvents,
|
||||
} from "../../src/shared.js";
|
||||
|
||||
export { PROVIDERS } from "../../src/shared.js";
|
||||
|
||||
/**
|
||||
* Dependencies that can be injected for testing
|
||||
*/
|
||||
export interface Dependencies {
|
||||
fetch: typeof globalThis.fetch;
|
||||
readFile: (path: string) => string | undefined;
|
||||
fileExists: (path: string) => boolean;
|
||||
// Use static commands/args only (no user-controlled input).
|
||||
execFileSync: (file: string, args: string[], options?: ExecFileSyncOptionsWithStringEncoding) => string;
|
||||
homedir: () => string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import type { Component, SettingItem, SettingsListTheme } from "@mariozechner/pi-tui";
|
||||
import {
|
||||
Input,
|
||||
fuzzyFilter,
|
||||
getEditorKeybindings,
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "@mariozechner/pi-tui";
|
||||
|
||||
export interface SettingsListOptions {
|
||||
enableSearch?: boolean;
|
||||
}
|
||||
|
||||
export const CUSTOM_OPTION = "__custom__";
|
||||
export const CUSTOM_LABEL = "custom";
|
||||
|
||||
export type { SettingItem, SettingsListTheme };
|
||||
|
||||
export class SettingsList implements Component {
|
||||
private items: SettingItem[];
|
||||
private filteredItems: SettingItem[];
|
||||
private theme: SettingsListTheme;
|
||||
private selectedIndex = 0;
|
||||
private maxVisible: number;
|
||||
private onChange: (id: string, newValue: string) => void;
|
||||
private onCancel: () => void;
|
||||
private searchInput?: Input;
|
||||
private searchEnabled: boolean;
|
||||
private submenuComponent: Component | null = null;
|
||||
private submenuItemIndex: number | null = null;
|
||||
|
||||
constructor(
|
||||
items: SettingItem[],
|
||||
maxVisible: number,
|
||||
theme: SettingsListTheme,
|
||||
onChange: (id: string, newValue: string) => void,
|
||||
onCancel: () => void,
|
||||
options: SettingsListOptions = {},
|
||||
) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
this.onChange = onChange;
|
||||
this.onCancel = onCancel;
|
||||
this.searchEnabled = options.enableSearch ?? false;
|
||||
|
||||
if (this.searchEnabled) {
|
||||
this.searchInput = new Input();
|
||||
}
|
||||
}
|
||||
|
||||
/** Update an item's currentValue */
|
||||
updateValue(id: string, newValue: string): void {
|
||||
const item = this.items.find((i) => i.id === id);
|
||||
if (item) {
|
||||
item.currentValue = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.submenuComponent?.invalidate?.();
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
// If submenu is active, render it instead
|
||||
if (this.submenuComponent) {
|
||||
return this.submenuComponent.render(width);
|
||||
}
|
||||
return this.renderMainList(width);
|
||||
}
|
||||
|
||||
private renderMainList(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
if (this.searchEnabled && this.searchInput) {
|
||||
lines.push(...this.searchInput.render(width));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (this.items.length === 0) {
|
||||
lines.push(this.theme.hint(" No settings available"));
|
||||
if (this.searchEnabled) {
|
||||
this.addHintLine(lines);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
if (displayItems.length === 0) {
|
||||
lines.push(this.theme.hint(" No matching settings"));
|
||||
this.addHintLine(lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Calculate visible range with scrolling
|
||||
const startIndex = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
this.selectedIndex - Math.floor(this.maxVisible / 2),
|
||||
displayItems.length - this.maxVisible,
|
||||
),
|
||||
);
|
||||
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
|
||||
|
||||
// Calculate max label width for alignment
|
||||
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
|
||||
|
||||
// Render visible items
|
||||
for (let i = startIndex; i < endIndex; i++) {
|
||||
const item = displayItems[i];
|
||||
if (!item) continue;
|
||||
const isSelected = i === this.selectedIndex;
|
||||
const prefix = isSelected ? this.theme.cursor : " ";
|
||||
const prefixWidth = visibleWidth(prefix);
|
||||
|
||||
// Pad label to align values
|
||||
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
|
||||
const labelText = this.theme.label(labelPadded, isSelected);
|
||||
|
||||
// Calculate space for value
|
||||
const separator = " ";
|
||||
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
|
||||
const valueMaxWidth = Math.max(1, width - usedWidth - 2);
|
||||
const optionLines = isSelected && item.values && item.values.length > 0
|
||||
? wrapTextWithAnsi(this.formatOptionsInline(item, item.values), valueMaxWidth)
|
||||
: null;
|
||||
const valueText = optionLines
|
||||
? optionLines[0] ?? ""
|
||||
: this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
|
||||
const line = prefix + labelText + separator + valueText;
|
||||
lines.push(truncateToWidth(line, width, ""));
|
||||
if (optionLines && optionLines.length > 1) {
|
||||
const indent = " ".repeat(prefixWidth + maxLabelWidth + visibleWidth(separator));
|
||||
for (const continuation of optionLines.slice(1)) {
|
||||
lines.push(truncateToWidth(indent + continuation, width, ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add scroll indicator if needed
|
||||
if (startIndex > 0 || endIndex < displayItems.length) {
|
||||
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
|
||||
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
|
||||
}
|
||||
|
||||
// Add description for selected item
|
||||
const selectedItem = displayItems[this.selectedIndex];
|
||||
if (selectedItem?.description) {
|
||||
lines.push("");
|
||||
const wrapWidth = Math.max(1, width - 4);
|
||||
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, wrapWidth);
|
||||
for (const line of wrappedDesc) {
|
||||
const prefixed = ` ${line}`;
|
||||
lines.push(this.theme.description(truncateToWidth(prefixed, width, "")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add hint
|
||||
this.addHintLine(lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
// If submenu is active, delegate all input to it
|
||||
// The submenu's onCancel (triggered by escape) will call done() which closes it
|
||||
if (this.submenuComponent) {
|
||||
this.submenuComponent.handleInput?.(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const kb = getEditorKeybindings();
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
|
||||
if (kb.matches(data, "selectUp")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
|
||||
} else if (kb.matches(data, "selectDown")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
|
||||
} else if (kb.matches(data, "cursorLeft")) {
|
||||
this.stepValue(-1);
|
||||
} else if (kb.matches(data, "cursorRight")) {
|
||||
this.stepValue(1);
|
||||
} else if (kb.matches(data, "selectConfirm") || data === " ") {
|
||||
this.activateItem();
|
||||
} else if (kb.matches(data, "selectCancel")) {
|
||||
this.onCancel();
|
||||
} else if (this.searchEnabled && this.searchInput) {
|
||||
const sanitized = data.replace(/ /g, "");
|
||||
if (!sanitized) {
|
||||
return;
|
||||
}
|
||||
this.searchInput.handleInput(sanitized);
|
||||
this.applyFilter(this.searchInput.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
private stepValue(direction: -1 | 1): void {
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
const item = displayItems[this.selectedIndex];
|
||||
if (!item || !item.values || item.values.length === 0) return;
|
||||
const values = item.values;
|
||||
let currentIndex = values.indexOf(item.currentValue);
|
||||
if (currentIndex === -1) {
|
||||
currentIndex = direction > 0 ? 0 : values.length - 1;
|
||||
}
|
||||
const nextIndex = (currentIndex + direction + values.length) % values.length;
|
||||
const newValue = values[nextIndex];
|
||||
if (newValue === CUSTOM_OPTION) {
|
||||
item.currentValue = newValue;
|
||||
this.onChange(item.id, newValue);
|
||||
return;
|
||||
}
|
||||
item.currentValue = newValue;
|
||||
this.onChange(item.id, newValue);
|
||||
}
|
||||
|
||||
private activateItem(): void {
|
||||
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
|
||||
if (!item) return;
|
||||
|
||||
const hasCustom = Boolean(item.values && item.values.includes(CUSTOM_OPTION));
|
||||
const currentIsCustom = hasCustom && item.values && !item.values.includes(item.currentValue);
|
||||
|
||||
if (item.submenu && hasCustom) {
|
||||
if (currentIsCustom || item.currentValue === CUSTOM_OPTION) {
|
||||
this.openSubmenu(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.submenu) {
|
||||
this.openSubmenu(item);
|
||||
}
|
||||
}
|
||||
|
||||
private closeSubmenu(): void {
|
||||
this.submenuComponent = null;
|
||||
// Restore selection to the item that opened the submenu
|
||||
if (this.submenuItemIndex !== null) {
|
||||
this.selectedIndex = this.submenuItemIndex;
|
||||
this.submenuItemIndex = null;
|
||||
}
|
||||
}
|
||||
|
||||
private applyFilter(query: string): void {
|
||||
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
private formatOptionsInline(item: SettingItem, values: string[]): string {
|
||||
const separator = this.theme.description(" • ");
|
||||
const hasCustom = values.includes(CUSTOM_OPTION);
|
||||
const currentIsCustom = hasCustom && !values.includes(item.currentValue);
|
||||
return values
|
||||
.map((value) => {
|
||||
const label = value === CUSTOM_OPTION
|
||||
? (currentIsCustom ? `${CUSTOM_LABEL} (${item.currentValue})` : CUSTOM_LABEL)
|
||||
: value;
|
||||
const selected = value === item.currentValue || (currentIsCustom && value === CUSTOM_OPTION);
|
||||
return this.theme.value(label, selected);
|
||||
})
|
||||
.join(separator);
|
||||
}
|
||||
|
||||
private openSubmenu(item: SettingItem): void {
|
||||
if (!item.submenu) return;
|
||||
this.submenuItemIndex = this.selectedIndex;
|
||||
this.submenuComponent = item.submenu(item.currentValue, (selectedValue) => {
|
||||
if (selectedValue !== undefined) {
|
||||
item.currentValue = selectedValue;
|
||||
this.onChange(item.id, selectedValue);
|
||||
}
|
||||
this.closeSubmenu();
|
||||
});
|
||||
}
|
||||
|
||||
private addHintLine(lines: string[]): void {
|
||||
lines.push("");
|
||||
lines.push(
|
||||
this.theme.hint(
|
||||
this.searchEnabled
|
||||
? " Type to search · ←/→ change · Enter/Space edit custom · Esc to cancel"
|
||||
: " ←/→ change · Enter/Space edit custom · Esc to cancel",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
/**
|
||||
* Usage refresh and provider selection controller.
|
||||
*/
|
||||
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { ProviderName, UsageSnapshot } from "../types.js";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import { detectProviderFromModel } from "../providers/detection.js";
|
||||
import { isExpectedMissingData } from "../errors.js";
|
||||
import { formatElapsedSince } from "../utils.js";
|
||||
import { fetchUsageForProvider, refreshStatusForProvider } from "./fetch.js";
|
||||
import type { Dependencies } from "../types.js";
|
||||
import { getCachedData, readCache } from "../cache.js";
|
||||
import { hasProviderCredentials } from "../providers/registry.js";
|
||||
|
||||
export interface UsageControllerState {
|
||||
currentProvider?: ProviderName;
|
||||
cachedUsage?: UsageSnapshot;
|
||||
lastSuccessAt?: number;
|
||||
providerCycleIndex: number;
|
||||
}
|
||||
|
||||
export interface UsageUpdate {
|
||||
provider?: ProviderName;
|
||||
usage?: UsageSnapshot;
|
||||
}
|
||||
|
||||
export type UsageUpdateHandler = (update: UsageUpdate) => void;
|
||||
|
||||
export function createUsageController(deps: Dependencies) {
|
||||
function isProviderAvailable(
|
||||
settings: Settings,
|
||||
provider: ProviderName,
|
||||
options?: { skipCredentials?: boolean }
|
||||
): boolean {
|
||||
const setting = settings.providers[provider];
|
||||
if (setting.enabled === "off" || setting.enabled === false) return false;
|
||||
if (setting.enabled === "on" || setting.enabled === true) return true;
|
||||
if (options?.skipCredentials) return true;
|
||||
return hasProviderCredentials(provider, deps);
|
||||
}
|
||||
|
||||
function getEnabledProviders(settings: Settings): ProviderName[] {
|
||||
return settings.providerOrder.filter((p) => isProviderAvailable(settings, p));
|
||||
}
|
||||
|
||||
function resolveProvider(
|
||||
ctx: ExtensionContext,
|
||||
settings: Settings,
|
||||
state: UsageControllerState,
|
||||
options?: { skipCredentials?: boolean }
|
||||
): ProviderName | undefined {
|
||||
const detected = detectProviderFromModel(ctx.model);
|
||||
if (detected && isProviderAvailable(settings, detected, options)) {
|
||||
return detected;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function emitUpdate(state: UsageControllerState, onUpdate: UsageUpdateHandler): void {
|
||||
onUpdate({
|
||||
provider: state.currentProvider,
|
||||
usage: state.cachedUsage,
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh(
|
||||
ctx: ExtensionContext,
|
||||
settings: Settings,
|
||||
state: UsageControllerState,
|
||||
onUpdate: UsageUpdateHandler,
|
||||
options?: { force?: boolean; allowStaleCache?: boolean; forceStatus?: boolean; skipFetch?: boolean }
|
||||
): Promise<void> {
|
||||
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
|
||||
if (!provider) {
|
||||
state.currentProvider = undefined;
|
||||
state.cachedUsage = undefined;
|
||||
emitUpdate(state, onUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
const providerChanged = provider !== state.currentProvider;
|
||||
state.currentProvider = provider;
|
||||
if (providerChanged) {
|
||||
state.cachedUsage = undefined;
|
||||
}
|
||||
|
||||
const cache = readCache();
|
||||
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
|
||||
if (!cachedEntry && options?.allowStaleCache) {
|
||||
cachedEntry = cache[provider] ?? null;
|
||||
}
|
||||
if (cachedEntry?.usage) {
|
||||
state.cachedUsage = {
|
||||
...cachedEntry.usage,
|
||||
status: cachedEntry.status,
|
||||
lastSuccessAt: cachedEntry.fetchedAt,
|
||||
};
|
||||
if (!cachedEntry.usage.error) {
|
||||
state.lastSuccessAt = cachedEntry.fetchedAt;
|
||||
}
|
||||
}
|
||||
emitUpdate(state, onUpdate);
|
||||
|
||||
if (options?.skipFetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchUsageForProvider(deps, settings, provider, options);
|
||||
const error = result.usage?.error;
|
||||
const fetchError = Boolean(error && !isExpectedMissingData(error));
|
||||
if (fetchError) {
|
||||
let fallback = state.cachedUsage;
|
||||
let fallbackFetchedAt = state.lastSuccessAt;
|
||||
if (!fallback || fallback.windows.length === 0) {
|
||||
const cachedEntry = cache[provider];
|
||||
const cachedUsage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
|
||||
fallback = cachedUsage && cachedUsage.windows.length > 0 ? cachedUsage : undefined;
|
||||
if (cachedEntry?.fetchedAt) fallbackFetchedAt = cachedEntry.fetchedAt;
|
||||
}
|
||||
if (fallback && fallback.windows.length > 0) {
|
||||
const lastSuccessAt = fallbackFetchedAt ?? state.lastSuccessAt;
|
||||
const elapsed = lastSuccessAt ? formatElapsedSince(lastSuccessAt) : undefined;
|
||||
const description = elapsed ? (elapsed === "just now" ? "just now" : `${elapsed} ago`) : "Fetch failed";
|
||||
state.cachedUsage = {
|
||||
...fallback,
|
||||
lastSuccessAt,
|
||||
error,
|
||||
status: { indicator: "minor", description },
|
||||
};
|
||||
} else {
|
||||
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
|
||||
}
|
||||
} else {
|
||||
const successAt = Date.now();
|
||||
state.cachedUsage = result.usage
|
||||
? { ...result.usage, status: result.status, lastSuccessAt: successAt }
|
||||
: undefined;
|
||||
if (result.usage && !result.usage.error) {
|
||||
state.lastSuccessAt = successAt;
|
||||
}
|
||||
}
|
||||
emitUpdate(state, onUpdate);
|
||||
}
|
||||
|
||||
async function refreshStatus(
|
||||
ctx: ExtensionContext,
|
||||
settings: Settings,
|
||||
state: UsageControllerState,
|
||||
onUpdate: UsageUpdateHandler,
|
||||
options?: { force?: boolean; allowStaleCache?: boolean; skipFetch?: boolean }
|
||||
): Promise<void> {
|
||||
const provider = resolveProvider(ctx, settings, state, { skipCredentials: options?.skipFetch });
|
||||
if (!provider) {
|
||||
state.currentProvider = undefined;
|
||||
state.cachedUsage = undefined;
|
||||
emitUpdate(state, onUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
const providerChanged = provider !== state.currentProvider;
|
||||
state.currentProvider = provider;
|
||||
if (providerChanged) {
|
||||
state.cachedUsage = undefined;
|
||||
}
|
||||
|
||||
const cache = readCache();
|
||||
let cachedEntry = await getCachedData(provider, settings.behavior.refreshInterval * 1000, cache);
|
||||
if (!cachedEntry && options?.allowStaleCache) {
|
||||
cachedEntry = cache[provider] ?? null;
|
||||
}
|
||||
if (cachedEntry?.usage) {
|
||||
state.cachedUsage = {
|
||||
...cachedEntry.usage,
|
||||
status: cachedEntry.status,
|
||||
lastSuccessAt: cachedEntry.fetchedAt,
|
||||
};
|
||||
if (!cachedEntry.usage.error) {
|
||||
state.lastSuccessAt = cachedEntry.fetchedAt;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.skipFetch) {
|
||||
emitUpdate(state, onUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await refreshStatusForProvider(deps, settings, provider, { force: options?.force });
|
||||
if (status && state.cachedUsage) {
|
||||
state.cachedUsage = { ...state.cachedUsage, status };
|
||||
}
|
||||
|
||||
emitUpdate(state, onUpdate);
|
||||
}
|
||||
|
||||
async function cycleProvider(
|
||||
ctx: ExtensionContext,
|
||||
settings: Settings,
|
||||
state: UsageControllerState,
|
||||
onUpdate: UsageUpdateHandler
|
||||
): Promise<void> {
|
||||
const enabledProviders = getEnabledProviders(settings);
|
||||
if (enabledProviders.length === 0) {
|
||||
state.currentProvider = undefined;
|
||||
state.cachedUsage = undefined;
|
||||
emitUpdate(state, onUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIndex = state.currentProvider
|
||||
? enabledProviders.indexOf(state.currentProvider)
|
||||
: -1;
|
||||
if (currentIndex >= 0) {
|
||||
state.providerCycleIndex = currentIndex;
|
||||
}
|
||||
|
||||
const total = enabledProviders.length;
|
||||
for (let i = 0; i < total; i += 1) {
|
||||
state.providerCycleIndex = (state.providerCycleIndex + 1) % total;
|
||||
const nextProvider = enabledProviders[state.providerCycleIndex];
|
||||
const result = await fetchUsageForProvider(deps, settings, nextProvider);
|
||||
if (!isUsageAvailable(result.usage)) {
|
||||
continue;
|
||||
}
|
||||
state.currentProvider = nextProvider;
|
||||
state.cachedUsage = result.usage ? { ...result.usage, status: result.status } : undefined;
|
||||
emitUpdate(state, onUpdate);
|
||||
return;
|
||||
}
|
||||
|
||||
state.currentProvider = undefined;
|
||||
state.cachedUsage = undefined;
|
||||
emitUpdate(state, onUpdate);
|
||||
}
|
||||
|
||||
function isUsageAvailable(usage: UsageSnapshot | undefined): usage is UsageSnapshot {
|
||||
if (!usage) return false;
|
||||
if (usage.windows.length > 0) return true;
|
||||
if (!usage.error) return false;
|
||||
return !isExpectedMissingData(usage.error);
|
||||
}
|
||||
|
||||
return {
|
||||
getEnabledProviders,
|
||||
resolveProvider,
|
||||
refresh,
|
||||
refreshStatus,
|
||||
cycleProvider,
|
||||
};
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* Usage fetching helpers with cache integration.
|
||||
*/
|
||||
|
||||
import type { Dependencies, ProviderName, ProviderStatus, UsageSnapshot } from "../types.js";
|
||||
import type { Settings } from "../settings-types.js";
|
||||
import type { ProviderUsageEntry } from "./types.js";
|
||||
import { createProvider } from "../providers/registry.js";
|
||||
import { fetchWithCache, getCachedData, readCache, updateCacheStatus, type Cache } from "../cache.js";
|
||||
import { fetchProviderStatusWithFallback, providerHasStatus } from "../providers/status.js";
|
||||
import { hasProviderCredentials } from "../providers/registry.js";
|
||||
import { isExpectedMissingData } from "../errors.js";
|
||||
|
||||
export function getCacheTtlMs(settings: Settings): number {
|
||||
return settings.behavior.refreshInterval * 1000;
|
||||
}
|
||||
|
||||
export function getMinRefreshIntervalMs(settings: Settings): number {
|
||||
return settings.behavior.minRefreshInterval * 1000;
|
||||
}
|
||||
|
||||
export function getStatusCacheTtlMs(settings: Settings): number {
|
||||
return settings.statusRefresh.refreshInterval * 1000;
|
||||
}
|
||||
|
||||
export function getStatusMinRefreshIntervalMs(settings: Settings): number {
|
||||
return settings.statusRefresh.minRefreshInterval * 1000;
|
||||
}
|
||||
|
||||
const PROVIDER_FETCH_CONCURRENCY = 3;
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
limit: number,
|
||||
mapper: (item: T, index: number) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
if (items.length === 0) return [];
|
||||
const results = new Array<R>(items.length);
|
||||
let nextIndex = 0;
|
||||
const workerCount = Math.min(limit, items.length);
|
||||
const workers = Array.from({ length: workerCount }, async () => {
|
||||
while (true) {
|
||||
const currentIndex = nextIndex++;
|
||||
if (currentIndex >= items.length) {
|
||||
return;
|
||||
}
|
||||
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function resolveStatusFetchedAt(entry?: { fetchedAt: number; statusFetchedAt?: number } | null): number | undefined {
|
||||
if (!entry) return undefined;
|
||||
return entry.statusFetchedAt ?? entry.fetchedAt;
|
||||
}
|
||||
|
||||
function isWithinMinInterval(fetchedAt: number | undefined, minIntervalMs: number): boolean {
|
||||
if (!fetchedAt || minIntervalMs <= 0) return false;
|
||||
return Date.now() - fetchedAt < minIntervalMs;
|
||||
}
|
||||
|
||||
function shouldRefreshStatus(
|
||||
settings: Settings,
|
||||
entry?: { fetchedAt: number; statusFetchedAt?: number } | null,
|
||||
options?: { force?: boolean }
|
||||
): boolean {
|
||||
const fetchedAt = resolveStatusFetchedAt(entry);
|
||||
const minIntervalMs = getStatusMinRefreshIntervalMs(settings);
|
||||
if (isWithinMinInterval(fetchedAt, minIntervalMs)) return false;
|
||||
if (options?.force) return true;
|
||||
const ttlMs = getStatusCacheTtlMs(settings);
|
||||
if (ttlMs <= 0) return true;
|
||||
if (!fetchedAt) return true;
|
||||
return Date.now() - fetchedAt >= ttlMs;
|
||||
}
|
||||
|
||||
export async function refreshStatusForProvider(
|
||||
deps: Dependencies,
|
||||
settings: Settings,
|
||||
provider: ProviderName,
|
||||
options?: { force?: boolean }
|
||||
): Promise<ProviderStatus | undefined> {
|
||||
const enabledSetting = settings.providers[provider].enabled;
|
||||
if (enabledSetting === "off" || enabledSetting === false) {
|
||||
return undefined;
|
||||
}
|
||||
if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!settings.providers[provider].fetchStatus) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cache = readCache();
|
||||
const entry = cache[provider];
|
||||
const providerInstance = createProvider(provider);
|
||||
const shouldFetch = providerHasStatus(provider, providerInstance) && shouldRefreshStatus(settings, entry, options);
|
||||
if (!shouldFetch) {
|
||||
return entry?.status;
|
||||
}
|
||||
const status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
|
||||
await updateCacheStatus(provider, status, { statusFetchedAt: Date.now() });
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function fetchUsageForProvider(
|
||||
deps: Dependencies,
|
||||
settings: Settings,
|
||||
provider: ProviderName,
|
||||
options?: { force?: boolean; forceStatus?: boolean }
|
||||
): Promise<{ usage?: UsageSnapshot; status?: ProviderStatus }> {
|
||||
const enabledSetting = settings.providers[provider].enabled;
|
||||
if (enabledSetting === "off" || enabledSetting === false) {
|
||||
return {};
|
||||
}
|
||||
if (enabledSetting === "auto" && !hasProviderCredentials(provider, deps)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const ttlMs = getCacheTtlMs(settings);
|
||||
const cache = readCache();
|
||||
const cachedEntry = cache[provider];
|
||||
const cachedStatus = cachedEntry?.status;
|
||||
const minIntervalMs = getMinRefreshIntervalMs(settings);
|
||||
if (cachedEntry?.usage && isWithinMinInterval(cachedEntry.fetchedAt, minIntervalMs)) {
|
||||
const usage = { ...cachedEntry.usage, status: cachedEntry.status } as UsageSnapshot;
|
||||
return { usage, status: cachedEntry.status };
|
||||
}
|
||||
const providerInstance = createProvider(provider);
|
||||
const shouldFetchStatus = Boolean(options?.forceStatus)
|
||||
&& settings.providers[provider].fetchStatus
|
||||
&& providerHasStatus(provider, providerInstance);
|
||||
|
||||
if (!options?.force) {
|
||||
const cachedUsage = await getCachedData(provider, ttlMs, cache);
|
||||
if (cachedUsage) {
|
||||
let status = cachedUsage.status;
|
||||
if (shouldFetchStatus) {
|
||||
status = await refreshStatusForProvider(deps, settings, provider, { force: options?.forceStatus ?? options?.force });
|
||||
}
|
||||
const usage = cachedUsage.usage ? { ...cachedUsage.usage, status } : undefined;
|
||||
return { usage, status };
|
||||
}
|
||||
}
|
||||
|
||||
return fetchWithCache(
|
||||
provider,
|
||||
ttlMs,
|
||||
async () => {
|
||||
const usage = await providerInstance.fetchUsage(deps);
|
||||
let status = cachedStatus;
|
||||
let statusFetchedAt = resolveStatusFetchedAt(cachedEntry);
|
||||
if (shouldFetchStatus) {
|
||||
status = await fetchProviderStatusWithFallback(provider, providerInstance, deps);
|
||||
statusFetchedAt = Date.now();
|
||||
} else if (!status) {
|
||||
status = { indicator: "none" as const };
|
||||
}
|
||||
|
||||
return { usage, status, statusFetchedAt };
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export async function getCachedUsageEntry(
|
||||
provider: ProviderName,
|
||||
settings: Settings,
|
||||
cacheSnapshot?: Cache
|
||||
): Promise<ProviderUsageEntry | undefined> {
|
||||
const ttlMs = getCacheTtlMs(settings);
|
||||
const cachedEntry = await getCachedData(provider, ttlMs, cacheSnapshot);
|
||||
const usage = cachedEntry?.usage ? { ...cachedEntry.usage, status: cachedEntry.status } : undefined;
|
||||
if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
|
||||
return undefined;
|
||||
}
|
||||
return { provider, usage };
|
||||
}
|
||||
|
||||
export async function getCachedUsageEntries(
|
||||
providers: ProviderName[],
|
||||
settings: Settings
|
||||
): Promise<ProviderUsageEntry[]> {
|
||||
const cache = readCache();
|
||||
const entries: ProviderUsageEntry[] = [];
|
||||
for (const provider of providers) {
|
||||
const entry = await getCachedUsageEntry(provider, settings, cache);
|
||||
if (entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function fetchUsageEntries(
|
||||
deps: Dependencies,
|
||||
settings: Settings,
|
||||
providers: ProviderName[],
|
||||
options?: { force?: boolean }
|
||||
): Promise<ProviderUsageEntry[]> {
|
||||
const concurrency = Math.max(1, Math.min(PROVIDER_FETCH_CONCURRENCY, providers.length));
|
||||
const results = await mapWithConcurrency(providers, concurrency, async (provider) => {
|
||||
const result = await fetchUsageForProvider(deps, settings, provider, options);
|
||||
const usage = result.usage
|
||||
? ({ ...result.usage, status: result.status } as UsageSnapshot)
|
||||
: undefined;
|
||||
if (!usage || (usage.error && isExpectedMissingData(usage.error))) {
|
||||
return undefined;
|
||||
}
|
||||
return { provider, usage } as ProviderUsageEntry;
|
||||
});
|
||||
return results.filter(Boolean) as ProviderUsageEntry[];
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* Usage data types shared across modules.
|
||||
*/
|
||||
|
||||
export type { ProviderUsageEntry } from "../../../src/shared.js";
|
||||
@@ -1,122 +0,0 @@
|
||||
/**
|
||||
* Utility functions for the sub-bar extension
|
||||
*/
|
||||
|
||||
import type { Dependencies } from "./types.js";
|
||||
import { MODEL_MULTIPLIERS } from "./config.js";
|
||||
|
||||
// Only allow simple CLI names (no spaces/paths) to avoid unsafe command execution.
|
||||
const SAFE_CLI_NAME = /^[a-zA-Z0-9._-]+$/;
|
||||
|
||||
/**
|
||||
* Format a reset date as a relative time string
|
||||
*/
|
||||
export function formatReset(date: Date): string {
|
||||
const diffMs = date.getTime() - Date.now();
|
||||
if (diffMs < 0) return "now";
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
const mins = diffMins % 60;
|
||||
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
const remHours = hours % 24;
|
||||
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format elapsed time since a timestamp (milliseconds)
|
||||
*/
|
||||
export function formatElapsedSince(timestamp: number): string {
|
||||
const diffMs = Date.now() - timestamp;
|
||||
if (diffMs < 60000) return "just now";
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
|
||||
const hours = Math.floor(diffMins / 60);
|
||||
const mins = diffMins % 60;
|
||||
if (hours < 24) return mins > 0 ? `${hours}h${mins}m` : `${hours}h`;
|
||||
|
||||
const days = Math.floor(hours / 24);
|
||||
const remHours = hours % 24;
|
||||
return remHours > 0 ? `${days}d${remHours}h` : `${days}d`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI escape codes from a string
|
||||
*/
|
||||
export function stripAnsi(text: string): string {
|
||||
return text.replace(/\x1B\[[0-9;?]*[A-Za-z]|\x1B\].*?\x07/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a string into tokens for fuzzy matching
|
||||
*/
|
||||
export function normalizeTokens(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Pre-computed token entries for model multiplier matching
|
||||
const MODEL_MULTIPLIER_TOKENS = Object.entries(MODEL_MULTIPLIERS).map(([label, multiplier]) => ({
|
||||
label,
|
||||
multiplier,
|
||||
tokens: normalizeTokens(label),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Get the request multiplier for a model ID
|
||||
* Uses fuzzy matching against known model names
|
||||
*/
|
||||
export function getModelMultiplier(modelId: string | undefined): number | undefined {
|
||||
if (!modelId) return undefined;
|
||||
const modelTokens = normalizeTokens(modelId);
|
||||
if (modelTokens.length === 0) return undefined;
|
||||
|
||||
let bestMatch: { multiplier: number; tokenCount: number } | undefined;
|
||||
for (const entry of MODEL_MULTIPLIER_TOKENS) {
|
||||
const isMatch = entry.tokens.every((token) => modelTokens.includes(token));
|
||||
if (!isMatch) continue;
|
||||
const tokenCount = entry.tokens.length;
|
||||
if (!bestMatch || tokenCount > bestMatch.tokenCount) {
|
||||
bestMatch = { multiplier: entry.multiplier, tokenCount };
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch?.multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command exists in PATH
|
||||
*/
|
||||
export function whichSync(cmd: string, deps: Dependencies): string | null {
|
||||
if (!SAFE_CLI_NAME.test(cmd)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return deps.execFileSync("which", [cmd], { encoding: "utf-8" }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an abort controller with a timeout
|
||||
*/
|
||||
export function createTimeoutController(timeoutMs: number): { controller: AbortController; clear: () => void } {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return {
|
||||
controller,
|
||||
clear: () => clearTimeout(timeoutId),
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["index.ts", "src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"include": ["index.ts", "shared.ts", "src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user