Files
ccs/ui/src/lib/utils.ts
T

615 lines
19 KiB
TypeScript

import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type {
CodexQuotaWindow,
CodexQuotaResult,
GeminiCliBucket,
GeminiCliQuotaResult,
QuotaResult,
} from './api-client';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Vibrant Tones Palette
const VIBRANT_TONES = [
'#f94144', // Strawberry Red
'#f3722c', // Pumpkin Spice
'#f8961e', // Carrot Orange
'#f9844a', // Atomic Tangerine
'#f9c74f', // Tuscan Sun
'#90be6d', // Willow Green
'#43aa8b', // Seaweed
'#4d908e', // Dark Cyan
'#577590', // Blue Slate
'#277da1', // Cerulean
];
// Provider color mapping (fixed colors for consistency)
const PROVIDER_COLORS: Record<string, string> = {
agy: '#f3722c', // Pumpkin
gemini: '#277da1', // Cerulean
codex: '#f8961e', // Carrot
vertex: '#577590', // Blue Slate
iflow: '#f94144', // Strawberry
qwen: '#f9c74f', // Tuscan
kiro: '#4d908e', // Dark Cyan (AWS-inspired)
copilot: '#43aa8b', // Seaweed (GitHub-inspired)
};
// Status colors (from Analytics Cost breakdown) - darker for light theme contrast
export const STATUS_COLORS = {
success: '#15803d', // Green-700 (was Seaweed #43aa8b)
degraded: '#b45309', // Amber-700 (was Ochre #e09f3e)
failed: '#b91c1c', // Red-700 (was Merlot #9e2a2b)
} as const;
export function getModelColor(model: string): string {
// FNV-1a hash algorithm
let hash = 0x811c9dc5;
for (let i = 0; i < model.length; i++) {
hash ^= model.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
// Ensure positive index
return VIBRANT_TONES[(hash >>> 0) % VIBRANT_TONES.length];
}
export function getProviderColor(provider: string): string {
const normalized = provider.toLowerCase();
return PROVIDER_COLORS[normalized] || getModelColor(provider);
}
/**
* Sort models by tier: Primary (Claude/GPT) > Gemini 3 Pro > Gemini 2.5 > Others
* Within each tier, sorts alphabetically by display name
*/
export function sortModelsByPriority<T extends { name: string; displayName?: string }>(
models: T[]
): T[] {
const getPriority = (model: T): number => {
const name = (model.displayName || model.name).toLowerCase();
// Tier 0: Primary models (Claude + GPT) - weekly limits, most valuable
if (name.includes('claude') || name.includes('gpt')) return 0;
// Tier 1: Gemini 3 Pro models - high capability
if (name.includes('gemini 3') || name.includes('gemini-3')) return 1;
// Tier 2: Gemini 2.5 Pro/Flash models - mid tier
if (name.includes('gemini 2.5') || name.includes('gemini-2.5')) return 2;
// Tier 3: Other Gemini models
if (name.includes('gemini')) return 3;
// Tier 4: Everything else
return 4;
};
return [...models].sort((a, b) => {
const priorityDiff = getPriority(a) - getPriority(b);
if (priorityDiff !== 0) return priorityDiff;
// Same priority: sort alphabetically by display name
const nameA = (a.displayName || a.name).toLowerCase();
const nameB = (b.displayName || b.name).toLowerCase();
return nameA.localeCompare(nameB);
});
}
/**
* Format reset time - relative for <24h, absolute date for >=24h (weekly limits)
*/
export function formatResetTime(resetTime: string | null): string | null {
if (!resetTime) return null;
try {
const reset = new Date(resetTime);
const now = new Date();
const diff = reset.getTime() - now.getTime();
if (diff <= 0) return 'soon';
const hours = Math.floor(diff / (1000 * 60 * 60));
// Weekly/long resets: show absolute date (e.g., "01/27, 12:07")
if (hours >= 24) {
return reset.toLocaleDateString(undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
// Daily resets: show relative time
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) return `in ${hours}h ${minutes}m`;
return `in ${minutes}m`;
} catch {
return null;
}
}
/**
* Get earliest reset time from models array
*/
export function getEarliestResetTime<T extends { resetTime: string | null }>(
models: T[]
): string | null {
return models.reduce(
(earliest, m) => {
if (!m.resetTime) return earliest;
if (!earliest) return m.resetTime;
return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest;
},
null as string | null
);
}
/**
* Filter to get Claude/GPT models (primary models we care about for quota)
* These have weekly limits vs Gemini's daily limits
*/
function filterPrimaryModels<T extends { name: string; displayName?: string }>(models: T[]): T[] {
return models.filter((m) => {
const name = (m.displayName || m.name || '').toLowerCase();
return name.includes('claude') || name.includes('gpt');
});
}
/**
* Calculate the minimum quota percentage from Claude/GPT models.
* Returns 0 if Claude/GPT models are missing (exhausted/removed from API response).
* Only returns null if no models at all.
*/
export function getMinClaudeQuota<
T extends { name: string; displayName?: string; percentage: number },
>(models: T[]): number | null {
if (models.length === 0) return null;
const primaryModels = filterPrimaryModels(models);
// If no Claude/GPT models in response, they're exhausted (0%)
if (primaryModels.length === 0) return 0;
const percentages = primaryModels
.map((m) => m.percentage)
.filter((p) => typeof p === 'number' && isFinite(p));
if (percentages.length === 0) return 0;
return Math.min(...percentages);
}
/**
* Get reset time for Claude/GPT models (primary models).
* Returns null only if no primary models present in response.
*/
export function getClaudeResetTime<
T extends { name: string; displayName?: string; resetTime: string | null },
>(models: T[]): string | null {
if (models.length === 0) return null;
const primaryModels = filterPrimaryModels(models);
if (primaryModels.length === 0) return null;
return primaryModels.reduce(
(earliest, m) => {
if (!m.resetTime) return earliest;
if (!earliest) return m.resetTime;
return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest;
},
null as string | null
);
}
// Known primary models to show when exhausted (removed from API response)
const KNOWN_PRIMARY_MODELS = [
{ name: 'claude-opus-4-5-thinking', displayName: 'Claude Opus 4.5 (Thinking)' },
{ name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5' },
{ name: 'claude-sonnet-4-5-thinking', displayName: 'Claude Sonnet 4.5 (Thinking)' },
{ name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)' },
];
/** Model tier for visual grouping */
export type ModelTier = 'primary' | 'gemini-3' | 'gemini-2' | 'other';
/** Model with tier info for grouped display */
export interface TieredModel {
name: string;
displayName: string;
percentage: number;
tier: ModelTier;
exhausted?: boolean;
}
/** Get tier label for display */
export function getTierLabel(tier: ModelTier): string {
switch (tier) {
case 'primary':
return 'Claude & GPT';
case 'gemini-3':
return 'Gemini 3';
case 'gemini-2':
return 'Gemini 2.5';
case 'other':
return 'Other';
}
}
/** Determine tier for a model */
function getModelTier(name: string): ModelTier {
const lower = name.toLowerCase();
if (lower.includes('claude') || lower.includes('gpt')) return 'primary';
if (lower.includes('gemini 3') || lower.includes('gemini-3')) return 'gemini-3';
if (lower.includes('gemini 2') || lower.includes('gemini-2')) return 'gemini-2';
return 'other';
}
/**
* Convert models to tiered format with exhausted primary models injected.
* Groups models by tier for visual display in tooltip.
*/
export function getModelsWithTiers<
T extends { name: string; displayName?: string; percentage: number },
>(models: T[]): TieredModel[] {
if (models.length === 0) return [];
const primaryModels = filterPrimaryModels(models);
const result: TieredModel[] = [];
// If primary models exhausted, add known ones with 0%
if (primaryModels.length === 0) {
for (const known of KNOWN_PRIMARY_MODELS) {
result.push({
name: known.name,
displayName: known.displayName,
percentage: 0,
tier: 'primary',
exhausted: true,
});
}
}
// Add all models with tier info
for (const m of models) {
const displayName = m.displayName || m.name;
const tier = getModelTier(displayName);
result.push({
name: m.name,
displayName,
percentage: m.percentage,
tier,
// Mark primary models at 0% as exhausted for red styling
exhausted: tier === 'primary' && m.percentage === 0,
});
}
// Sort by tier priority, then alphabetically within tier
const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other'];
return result.sort((a, b) => {
const tierDiff = tierOrder.indexOf(a.tier) - tierOrder.indexOf(b.tier);
if (tierDiff !== 0) return tierDiff;
return a.displayName.localeCompare(b.displayName);
});
}
/**
* Group tiered models by tier for sectioned display
*/
export function groupModelsByTier(models: TieredModel[]): Map<ModelTier, TieredModel[]> {
const groups = new Map<ModelTier, TieredModel[]>();
for (const m of models) {
const existing = groups.get(m.tier) || [];
existing.push(m);
groups.set(m.tier, existing);
}
return groups;
}
export type CodexWindowKind =
| 'usage-5h'
| 'usage-weekly'
| 'code-review-5h'
| 'code-review-weekly'
| 'code-review'
| 'unknown';
/**
* Map raw Codex API window labels into semantic buckets.
*/
export function getCodexWindowKind(label: string): CodexWindowKind {
const lower = (label || '').toLowerCase();
const isCodeReview = lower.includes('code review') || lower.includes('code_review');
const isPrimary = lower.includes('primary');
const isSecondary = lower.includes('secondary');
if (isCodeReview) {
if (isPrimary) return 'code-review-5h';
if (isSecondary) return 'code-review-weekly';
return 'code-review';
}
if (isPrimary) return 'usage-5h';
if (isSecondary) return 'usage-weekly';
return 'unknown';
}
type CodexWindowSummary = Pick<CodexQuotaWindow, 'label' | 'resetAfterSeconds'>;
/**
* Infer code-review window cadence by comparing against usage windows.
* This keeps labels stable as countdown values decrease over time.
*/
function inferCodeReviewCadence(
window: CodexWindowSummary,
allWindows: CodexWindowSummary[]
): '5h' | 'weekly' | null {
const kind = getCodexWindowKind(window.label);
if (kind === 'code-review-weekly') return 'weekly';
const reset = window.resetAfterSeconds;
if (typeof reset !== 'number' || !isFinite(reset) || reset <= 0) return null;
const usage5h = allWindows.find(
(w) =>
getCodexWindowKind(w.label) === 'usage-5h' &&
typeof w.resetAfterSeconds === 'number' &&
isFinite(w.resetAfterSeconds) &&
w.resetAfterSeconds > 0
);
const usageWeekly = allWindows.find(
(w) =>
getCodexWindowKind(w.label) === 'usage-weekly' &&
typeof w.resetAfterSeconds === 'number' &&
isFinite(w.resetAfterSeconds) &&
w.resetAfterSeconds > 0
);
if (!usage5h || !usageWeekly) return null;
const diffTo5h = Math.abs(reset - (usage5h.resetAfterSeconds as number));
const diffToWeekly = Math.abs(reset - (usageWeekly.resetAfterSeconds as number));
return diffToWeekly <= diffTo5h ? 'weekly' : '5h';
}
export function getCodexWindowDisplayLabel(
labelOrWindow: string | CodexWindowSummary,
allWindows: CodexWindowSummary[] = []
): string {
const label = typeof labelOrWindow === 'string' ? labelOrWindow : labelOrWindow.label;
const currentWindow: CodexWindowSummary =
typeof labelOrWindow === 'string'
? { label, resetAfterSeconds: null }
: { label, resetAfterSeconds: labelOrWindow.resetAfterSeconds };
const context = allWindows.length > 0 ? allWindows : [currentWindow];
switch (getCodexWindowKind(label)) {
case 'usage-5h':
return '5h usage limit';
case 'usage-weekly':
return 'Weekly usage limit';
case 'code-review-5h':
case 'code-review-weekly':
case 'code-review': {
const inferred = inferCodeReviewCadence(currentWindow, context);
if (inferred === '5h') return 'Code review (5h)';
if (inferred === 'weekly') return 'Code review (weekly)';
return 'Code review';
}
case 'unknown':
return label;
}
}
export interface CodexQuotaBreakdown {
fiveHourWindow: CodexQuotaWindow | null;
weeklyWindow: CodexQuotaWindow | null;
codeReviewWindows: CodexQuotaWindow[];
unknownWindows: CodexQuotaWindow[];
}
/**
* Break down Codex windows into core usage windows (5h + weekly) and auxiliary windows.
*/
export function getCodexQuotaBreakdown(windows: CodexQuotaWindow[]): CodexQuotaBreakdown {
if (!windows || windows.length === 0) {
return {
fiveHourWindow: null,
weeklyWindow: null,
codeReviewWindows: [],
unknownWindows: [],
};
}
let fiveHourWindow: CodexQuotaWindow | null = null;
let weeklyWindow: CodexQuotaWindow | null = null;
const codeReviewWindows: CodexQuotaWindow[] = [];
const unknownWindows: CodexQuotaWindow[] = [];
const nonCodeReviewWindows: CodexQuotaWindow[] = [];
for (const window of windows) {
const kind = getCodexWindowKind(window.label);
switch (kind) {
case 'usage-5h':
if (!fiveHourWindow) fiveHourWindow = window;
nonCodeReviewWindows.push(window);
break;
case 'usage-weekly':
if (!weeklyWindow) weeklyWindow = window;
nonCodeReviewWindows.push(window);
break;
case 'code-review-5h':
case 'code-review-weekly':
case 'code-review':
codeReviewWindows.push(window);
break;
case 'unknown':
unknownWindows.push(window);
nonCodeReviewWindows.push(window);
break;
}
}
// Fallback for API label changes: infer 5h/weekly from reset horizon when explicit labels are absent.
if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) {
const withReset = nonCodeReviewWindows
.filter((w) => typeof w.resetAfterSeconds === 'number' && w.resetAfterSeconds >= 0)
.sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0));
if (!fiveHourWindow) {
fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null;
}
if (!weeklyWindow) {
weeklyWindow =
withReset.length > 1
? withReset[withReset.length - 1]
: nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null;
}
}
return {
fiveHourWindow,
weeklyWindow,
codeReviewWindows,
unknownWindows,
};
}
/**
* Get minimum remaining percentage across Codex rate limit windows
*/
export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null {
if (!windows || windows.length === 0) return null;
const { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows);
const usageWindows = [fiveHourWindow, weeklyWindow].filter(
(w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index
);
// Primary account quota should be driven by core usage windows, not code-review windows.
const sourceWindows = usageWindows.length > 0 ? usageWindows : windows;
const percentages = sourceWindows.map((w) => w.remainingPercent);
return Math.min(...percentages);
}
/**
* Get earliest reset time from Codex windows
*/
export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null {
if (!windows || windows.length === 0) return null;
const { fiveHourWindow, weeklyWindow } = getCodexQuotaBreakdown(windows);
const usageWindows = [fiveHourWindow, weeklyWindow].filter(
(w, index, arr): w is CodexQuotaWindow => !!w && arr.indexOf(w) === index
);
const sourceWindows = usageWindows.length > 0 ? usageWindows : windows;
const resets = sourceWindows.map((w) => w.resetAt).filter((t): t is string => t !== null);
if (resets.length === 0) return null;
return resets.sort()[0];
}
/**
* Get minimum remaining percentage across Gemini CLI buckets
*/
export function getMinGeminiQuota(buckets: GeminiCliBucket[]): number | null {
if (!buckets || buckets.length === 0) return null;
const percentages = buckets.map((b) => b.remainingPercent);
return Math.min(...percentages);
}
/**
* Get earliest reset time from Gemini buckets
*/
export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null {
if (!buckets || buckets.length === 0) return null;
const resets = buckets.map((b) => b.resetTime).filter((t): t is string => t !== null);
if (resets.length === 0) return null;
return resets.sort()[0];
}
// ==================== Unified Quota Type Guards ====================
/** Unified quota result type for provider-agnostic handling */
export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult;
/** Type guard: Check if quota result is from Antigravity (agy) provider */
export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult {
return 'models' in quota && Array.isArray((quota as QuotaResult).models);
}
/** Type guard: Check if quota result is from Codex provider */
export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult {
return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows);
}
/** Type guard: Check if quota result is from Gemini CLI provider */
export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult {
return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets);
}
// ==================== Unified Quota Helpers ====================
/**
* Get minimum quota percentage for any provider
* Centralizes provider-specific logic to eliminate duplication
*/
export function getProviderMinQuota(
provider: string,
quota: UnifiedQuotaResult | null | undefined
): number | null {
if (!quota?.success) return null;
switch (provider) {
case 'agy':
if (isAgyQuotaResult(quota)) {
return getMinClaudeQuota(quota.models);
}
return null;
case 'codex':
if (isCodexQuotaResult(quota)) {
return getMinCodexQuota(quota.windows);
}
return null;
case 'gemini':
if (isGeminiQuotaResult(quota)) {
return getMinGeminiQuota(quota.buckets);
}
return null;
default:
return null;
}
}
/**
* Get earliest reset time for any provider
* Centralizes provider-specific logic to eliminate duplication
*/
export function getProviderResetTime(
provider: string,
quota: UnifiedQuotaResult | null | undefined
): string | null {
if (!quota?.success) return null;
switch (provider) {
case 'agy':
if (isAgyQuotaResult(quota)) {
return getClaudeResetTime(quota.models);
}
return null;
case 'codex':
if (isCodexQuotaResult(quota)) {
return getCodexResetTime(quota.windows);
}
return null;
case 'gemini':
if (isGeminiQuotaResult(quota)) {
return getGeminiResetTime(quota.buckets);
}
return null;
default:
return null;
}
}