mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-14 08:17:56 +00:00
feat: add API usage widgets (session, weekly, reset, context bar) (#168)
* feat: add API usage widgets (session, weekly, reset timer, context bar) Add four new widgets that display Claude Code API usage data: - session-usage: 5-hour session utilization with progress bar - weekly-usage: 7-day utilization with progress bar - reset-timer: countdown to session reset - context-bar: context window usage with progress bar Fetches data from /api/oauth/usage using existing OAuth credentials (macOS Keychain + Linux file fallback). Includes 180s cache with 30s rate limiting to minimize API calls. Also enhances ContextPercentage widget to use context_window data from Claude Code's stdin JSON when available. Closes #94 * fix(api-usage): zod-validate usage data and move cache to ~/.cache/ccstatusline - parse credentials/cache/API response via zod with nullable field support\n- move usage cache+lock to ~/.cache/ccstatusline/usage.{json,lock}\n- add missing getCategory() methods on API usage widgets\n- resolve context percentage brace-style lint issue * Refactor usage widgets and unify timer/usage display modes Split API usage widgets into dedicated files and a shared usage utility, replacing the monolithic ApiUsage widget module. Key changes: - add src/utils/usage.ts for API fetch/caching, parsing, error mapping, progress-bar helpers, and shared 5-hour window math - move widgets to standalone files: SessionUsage, WeeklyUsage, ResetTimer, ContextBar - remove src/widgets/ApiUsage.tsx and update widget exports/registry wiring - make BlockTimer usage-aware with usage API first and JSONL fallback via shared resolver - make ResetTimer follow BlockTimer UX: time/progress/short-progress modes, raw value support, invert toggle, and JSONL fallback when usage timing is unavailable - make SessionUsage and WeeklyUsage match timer interaction model: progress mode cycling, invert toggle, invert-clearing when returning to text mode, and raw-value support - update ItemsEditor to hide/disable invert keybind when widget is in text mode - remove eager block-metric parsing from ccstatusline render path so JSONL fallback is only invoked when usage timing is missing - add comprehensive tests for usage window fallback behavior and widget mode/toggle/raw output behavior Validation: - bun test - bun run lint * Prefer status JSON context-window metrics with robust fallbacks - add a shared context-window metrics utility to normalize context_window fields (window size, percentages, usage, and token snapshots) - make context window size from status JSON authoritative for context calculations when present; derive usable limit as 80% of reported size - keep model-id fallback behavior for missing context_window data, including case-insensitive [1m]/[1M] detection - update Context %, Context % (usable), Context Length, and Context Bar to use status JSON first and fall back to JSONL token metrics when needed - update Session Clock to use cost.total_duration_ms first with JSONL duration fallback - keep Tokens Input/Output JSON-first, while reverting Tokens Cached/Total to cumulative session JSONL totals - simplify render pipeline behavior to always parse current session JSONL token metrics when transcript_path exists - add/expand tests for context-window parsing, denominator precedence, 1M-without-suffix scenarios, case-insensitive suffix fallback, token widget source behavior, context bar rendering, and session clock behavior * Align Context Bar with Context Length semantics - switch Context Bar usage basis from total current_usage (input+output+cache) to context-length usage (input+cache), matching Context Length widget - keep statusline JSON-first behavior with JSONL/model fallbacks unchanged - keep short-by-default progress mode and long/short toggle behavior - update ContextBar tests for new token basis and progress-mode expectations * usage: honor CLAUDE_CONFIG_DIR and use runtime-safe HTTPS API fetch * Fix context bar overflow and preserve usage error states * Group usage widgets under new Usage picker category * Rename context remaining toggle to used/remaining shortcut --------- Co-authored-by: Peter van Velzen <pvvelzen@emico.nl> Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
This commit is contained in:
37 files changed
+2535
-97
No files matched your search
+8
-19
@@ -2,10 +2,7 @@
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { runTUI } from './tui';
|
||||
import type {
|
||||
BlockMetrics,
|
||||
TokenMetrics
|
||||
} from './types';
|
||||
import type { TokenMetrics } from './types';
|
||||
import type { RenderContext } from './types/RenderContext';
|
||||
import type { StatusJSON } from './types/StatusJSON';
|
||||
import { StatusJSONSchema } from './types/StatusJSON';
|
||||
@@ -15,7 +12,6 @@ import {
|
||||
saveSettings
|
||||
} from './utils/config';
|
||||
import {
|
||||
getCachedBlockMetrics,
|
||||
getSessionDuration,
|
||||
getTokenMetrics
|
||||
} from './utils/jsonl';
|
||||
@@ -25,6 +21,11 @@ import {
|
||||
renderStatusLine
|
||||
} from './utils/renderer';
|
||||
|
||||
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
const durationMs = data.cost?.total_duration_ms;
|
||||
return typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0;
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string | null> {
|
||||
// Check if stdin is a TTY (terminal) - if it is, there's no piped data
|
||||
if (process.stdin.isTTY) {
|
||||
@@ -79,36 +80,24 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
// Get all lines to render
|
||||
const lines = settings.lines;
|
||||
|
||||
// Get token metrics if needed (check all lines)
|
||||
const hasTokenItems = lines.some(line => line.some(item => ['tokens-input', 'tokens-output', 'tokens-cached', 'tokens-total', 'context-length', 'context-percentage', 'context-percentage-usable'].includes(item.type)));
|
||||
|
||||
// Check if session clock is needed
|
||||
const hasSessionClock = lines.some(line => line.some(item => item.type === 'session-clock'));
|
||||
|
||||
// Check if block timer is needed
|
||||
const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer'));
|
||||
|
||||
let tokenMetrics: TokenMetrics | null = null;
|
||||
if (hasTokenItems && data.transcript_path) {
|
||||
if (data.transcript_path) {
|
||||
tokenMetrics = await getTokenMetrics(data.transcript_path);
|
||||
}
|
||||
|
||||
let sessionDuration: string | null = null;
|
||||
if (hasSessionClock && data.transcript_path) {
|
||||
if (hasSessionClock && !hasSessionDurationInStatusJson(data) && data.transcript_path) {
|
||||
sessionDuration = await getSessionDuration(data.transcript_path);
|
||||
}
|
||||
|
||||
let blockMetrics: BlockMetrics | null = null;
|
||||
if (hasBlockTimer) {
|
||||
blockMetrics = getCachedBlockMetrics();
|
||||
}
|
||||
|
||||
// Create render context
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
tokenMetrics,
|
||||
sessionDuration,
|
||||
blockMetrics,
|
||||
isPreview: false
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import React, { useState } from 'react';
|
||||
|
||||
import type { Settings } from '../../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetItem,
|
||||
WidgetItemType
|
||||
@@ -122,6 +123,23 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
};
|
||||
};
|
||||
|
||||
const shouldShowCustomKeybind = (widget: WidgetItem, keybind: CustomKeybind): boolean => {
|
||||
if (keybind.action !== 'toggle-invert') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const mode = widget.metadata?.display;
|
||||
return mode === 'progress' || mode === 'progress-short';
|
||||
};
|
||||
|
||||
const getVisibleCustomKeybinds = (widgetImpl: Widget, widget: WidgetItem): CustomKeybind[] => {
|
||||
if (!widgetImpl.getCustomKeybinds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return widgetImpl.getCustomKeybinds().filter(keybind => shouldShowCustomKeybind(widget, keybind));
|
||||
};
|
||||
|
||||
const openWidgetPicker = (action: WidgetPickerAction) => {
|
||||
if (widgetCatalog.length === 0) {
|
||||
return;
|
||||
@@ -437,7 +455,7 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
if (widgetImpl.getCustomKeybinds) {
|
||||
const customKeybinds = widgetImpl.getCustomKeybinds();
|
||||
const customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
|
||||
const matchedKeybind = customKeybinds.find(kb => kb.key === input);
|
||||
|
||||
if (matchedKeybind && !key.ctrl) {
|
||||
@@ -517,15 +535,13 @@ export const ItemsEditor: React.FC<ItemsEditorProps> = ({ widgets, onUpdate, onB
|
||||
|
||||
// Check if widget supports raw value using registry
|
||||
let canToggleRaw = false;
|
||||
let customKeybinds: { key: string; label: string; action: string }[] = [];
|
||||
let customKeybinds: CustomKeybind[] = [];
|
||||
if (currentWidget && !isSeparator && !isFlexSeparator) {
|
||||
const widgetImpl = getWidget(currentWidget.type);
|
||||
if (widgetImpl) {
|
||||
canToggleRaw = widgetImpl.supportsRawValue();
|
||||
// Get custom keybinds from the widget
|
||||
if (widgetImpl.getCustomKeybinds) {
|
||||
customKeybinds = widgetImpl.getCustomKeybinds();
|
||||
}
|
||||
customKeybinds = getVisibleCustomKeybinds(widgetImpl, currentWidget);
|
||||
} else {
|
||||
canToggleRaw = false;
|
||||
}
|
||||
|
||||
+17
-1
@@ -24,7 +24,23 @@ export const StatusJSONSchema = z.looseObject({
|
||||
total_api_duration_ms: z.number().optional(),
|
||||
total_lines_added: z.number().optional(),
|
||||
total_lines_removed: z.number().optional()
|
||||
}).optional()
|
||||
}).optional(),
|
||||
context_window: z.object({
|
||||
context_window_size: z.number().nullable().optional(),
|
||||
total_input_tokens: z.number().nullable().optional(),
|
||||
total_output_tokens: z.number().nullable().optional(),
|
||||
current_usage: z.union([
|
||||
z.number(),
|
||||
z.object({
|
||||
input_tokens: z.number().optional(),
|
||||
output_tokens: z.number().optional(),
|
||||
cache_creation_input_tokens: z.number().optional(),
|
||||
cache_read_input_tokens: z.number().optional()
|
||||
})
|
||||
]).nullable().optional(),
|
||||
used_percentage: z.number().nullable().optional(),
|
||||
remaining_percentage: z.number().nullable().optional()
|
||||
}).nullable().optional()
|
||||
});
|
||||
|
||||
export type StatusJSON = z.infer<typeof StatusJSONSchema>;
|
||||
@@ -8,6 +8,68 @@ import type { RenderContext } from '../../types';
|
||||
import { calculateContextPercentage } from '../context-percentage';
|
||||
|
||||
describe('calculateContextPercentage', () => {
|
||||
describe('Status JSON context_window', () => {
|
||||
it('should prefer context_window used_percentage over token metrics', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-3-5-sonnet-20241022' },
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
used_percentage: 12.5
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 100000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(12.5);
|
||||
});
|
||||
|
||||
it('should derive percentage from current usage and window size when used_percentage is missing', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 20000,
|
||||
output_tokens: 10000,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(20);
|
||||
});
|
||||
|
||||
it('should use context_window_size as denominator when falling back to token metrics', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-3-5-sonnet-20241022' },
|
||||
context_window: { context_window_size: 1000000 }
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
const percentage = calculateContextPercentage(context);
|
||||
expect(percentage).toBe(4.2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sonnet 4.5 with 1M context window', () => {
|
||||
it('should calculate percentage using 1M denominator with [1m] suffix', () => {
|
||||
const context: RenderContext = {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getContextWindowMetrics } from '../context-window';
|
||||
|
||||
describe('getContextWindowMetrics', () => {
|
||||
it('returns null metrics when context_window is missing', () => {
|
||||
expect(getContextWindowMetrics(undefined)).toEqual({
|
||||
windowSize: null,
|
||||
usedTokens: null,
|
||||
contextLengthTokens: null,
|
||||
usedPercentage: null,
|
||||
remainingPercentage: null,
|
||||
totalInputTokens: null,
|
||||
totalOutputTokens: null,
|
||||
cachedTokens: null,
|
||||
totalTokens: null
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts totals and usage from current_usage object', () => {
|
||||
const metrics = getContextWindowMetrics({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
total_input_tokens: 4567,
|
||||
total_output_tokens: 890,
|
||||
current_usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 200,
|
||||
cache_creation_input_tokens: 300,
|
||||
cache_read_input_tokens: 400
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
windowSize: 200000,
|
||||
usedTokens: 1900,
|
||||
contextLengthTokens: 1700,
|
||||
usedPercentage: 0.95,
|
||||
remainingPercentage: 99.05,
|
||||
totalInputTokens: 4567,
|
||||
totalOutputTokens: 890,
|
||||
cachedTokens: 700,
|
||||
totalTokens: 1900
|
||||
});
|
||||
});
|
||||
|
||||
it('derives token usage from used_percentage when current_usage is missing', () => {
|
||||
const metrics = getContextWindowMetrics({
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
used_percentage: 12.5,
|
||||
remaining_percentage: 87.5,
|
||||
total_input_tokens: 5000,
|
||||
total_output_tokens: 1000
|
||||
}
|
||||
});
|
||||
|
||||
expect(metrics).toEqual({
|
||||
windowSize: 200000,
|
||||
usedTokens: 25000,
|
||||
contextLengthTokens: 25000,
|
||||
usedPercentage: 12.5,
|
||||
remainingPercentage: 87.5,
|
||||
totalInputTokens: 5000,
|
||||
totalOutputTokens: 1000,
|
||||
cachedTokens: null,
|
||||
totalTokens: 6000
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,22 @@ import {
|
||||
import { getContextConfig } from '../model-context';
|
||||
|
||||
describe('getContextConfig', () => {
|
||||
describe('Status JSON context window size override', () => {
|
||||
it('should use context_window_size as max tokens when provided', () => {
|
||||
const config = getContextConfig('claude-3-5-sonnet-20241022', 1000000);
|
||||
|
||||
expect(config.maxTokens).toBe(1000000);
|
||||
expect(config.usableTokens).toBe(800000);
|
||||
});
|
||||
|
||||
it('should prioritize context_window_size over [1m] model suffix', () => {
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929[1m]', 200000);
|
||||
|
||||
expect(config.maxTokens).toBe(200000);
|
||||
expect(config.usableTokens).toBe(160000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Models with [1m] suffix', () => {
|
||||
it('should return 1M context window for claude-sonnet-4-5 with [1m] suffix', () => {
|
||||
const config = getContextConfig('claude-sonnet-4-5-20250929[1m]');
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { execFileSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
interface UsageProbeResult {
|
||||
first: { error?: string };
|
||||
second: { error?: string };
|
||||
lockExists: boolean;
|
||||
}
|
||||
|
||||
describe('fetchUsageData error handling', () => {
|
||||
it('preserves root errors within lock window and avoids locking on no-credentials', () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-usage-test-'));
|
||||
const probeScriptPath = path.join(tempRoot, 'probe-usage.mjs');
|
||||
const usageModulePath = fileURLToPath(new URL('../usage.ts', import.meta.url));
|
||||
|
||||
const noCredentialsHome = path.join(tempRoot, 'home-no-credentials');
|
||||
const apiErrorHome = path.join(tempRoot, 'home-api-error');
|
||||
const apiErrorBin = path.join(tempRoot, 'bin-api-error');
|
||||
const apiErrorClaudeConfig = path.join(tempRoot, 'claude-api-error');
|
||||
const securityScript = path.join(apiErrorBin, 'security');
|
||||
const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json');
|
||||
|
||||
fs.mkdirSync(noCredentialsHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorBin, { recursive: true });
|
||||
fs.mkdirSync(apiErrorClaudeConfig, { recursive: true });
|
||||
|
||||
fs.writeFileSync(securityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
|
||||
fs.chmodSync(securityScript, 0o755);
|
||||
fs.writeFileSync(credentialsFile, JSON.stringify({ claudeAiOauth: { accessToken: 'test-token' } }));
|
||||
|
||||
const probeScript = `
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fetchUsageData } from ${JSON.stringify(usageModulePath)};
|
||||
|
||||
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
|
||||
const nowMs = Date.now() + (10 * 365 * 24 * 60 * 60 * 1000);
|
||||
Date.now = () => nowMs;
|
||||
|
||||
if (process.env.TEST_FORCE_BAD_EXEC_PATH === '1') {
|
||||
Object.defineProperty(process, 'execPath', {
|
||||
value: '/nonexistent-runtime',
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
|
||||
const first = fetchUsageData();
|
||||
const second = fetchUsageData();
|
||||
process.stdout.write(JSON.stringify({
|
||||
first,
|
||||
second,
|
||||
lockExists: fs.existsSync(lockFile)
|
||||
}));
|
||||
`;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(probeScriptPath, probeScript);
|
||||
|
||||
const noCredentialsOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: noCredentialsHome,
|
||||
PATH: '/nonexistent',
|
||||
TEST_FORCE_BAD_EXEC_PATH: '0'
|
||||
}
|
||||
});
|
||||
|
||||
const noCredentialsResult = JSON.parse(noCredentialsOutput) as UsageProbeResult;
|
||||
expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.lockExists).toBe(false);
|
||||
|
||||
const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: apiErrorHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
TEST_FORCE_BAD_EXEC_PATH: '1'
|
||||
}
|
||||
});
|
||||
|
||||
const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult;
|
||||
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { BlockMetrics } from '../../types';
|
||||
import { getCachedBlockMetrics } from '../jsonl';
|
||||
import {
|
||||
FIVE_HOUR_BLOCK_MS,
|
||||
formatUsageDuration,
|
||||
getUsageWindowFromResetAt,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../usage';
|
||||
|
||||
vi.mock('../jsonl', () => ({ getCachedBlockMetrics: vi.fn() }));
|
||||
|
||||
const mockGetCachedBlockMetrics = getCachedBlockMetrics as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
mockReturnValue: (value: BlockMetrics | null) => void;
|
||||
};
|
||||
|
||||
describe('usage window helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('parses usage reset timestamp into elapsed and remaining metrics', () => {
|
||||
const nowMs = Date.parse('2026-03-02T20:00:00.000Z');
|
||||
const resetAt = '2026-03-02T22:00:00.000Z';
|
||||
|
||||
const window = getUsageWindowFromResetAt(resetAt, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
|
||||
expect(window?.remainingMs).toBe(2 * 60 * 60 * 1000);
|
||||
expect(window?.elapsedPercent).toBeCloseTo(60, 5);
|
||||
expect(window?.remainingPercent).toBeCloseTo(40, 5);
|
||||
expect(window?.sessionDurationMs).toBe(FIVE_HOUR_BLOCK_MS);
|
||||
});
|
||||
|
||||
it('uses usage data first and does not parse JSONL when reset timestamp exists', () => {
|
||||
const nowMs = Date.parse('2026-03-02T20:00:00.000Z');
|
||||
const fallbackMetrics: BlockMetrics = {
|
||||
startTime: new Date('2026-03-02T15:00:00.000Z'),
|
||||
lastActivity: new Date('2026-03-02T20:00:00.000Z')
|
||||
};
|
||||
|
||||
mockGetCachedBlockMetrics.mockReturnValue(fallbackMetrics);
|
||||
|
||||
const window = resolveUsageWindowWithFallback({ sessionResetAt: '2026-03-02T22:00:00.000Z' }, undefined, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('uses provided block metrics fallback without parsing JSONL', () => {
|
||||
const nowMs = Date.parse('2026-03-02T18:30:00.000Z');
|
||||
const providedMetrics: BlockMetrics = {
|
||||
startTime: new Date('2026-03-02T15:00:00.000Z'),
|
||||
lastActivity: new Date('2026-03-02T18:30:00.000Z')
|
||||
};
|
||||
|
||||
const window = resolveUsageWindowWithFallback({}, providedMetrics, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3.5 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('parses JSONL fallback only when usage reset data is missing', () => {
|
||||
const nowMs = Date.parse('2026-03-02T18:00:00.000Z');
|
||||
const fallbackMetrics: BlockMetrics = {
|
||||
startTime: new Date('2026-03-02T15:00:00.000Z'),
|
||||
lastActivity: new Date('2026-03-02T18:00:00.000Z')
|
||||
};
|
||||
|
||||
mockGetCachedBlockMetrics.mockReturnValue(fallbackMetrics);
|
||||
|
||||
const window = resolveUsageWindowWithFallback({}, undefined, nowMs);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.elapsedMs).toBe(3 * 60 * 60 * 1000);
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('returns null when neither usage reset data nor JSONL fallback is available', () => {
|
||||
mockGetCachedBlockMetrics.mockReturnValue(null);
|
||||
|
||||
const window = resolveUsageWindowWithFallback({}, undefined, Date.now());
|
||||
|
||||
expect(window).toBeNull();
|
||||
expect(mockGetCachedBlockMetrics.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('formats duration in block timer style', () => {
|
||||
expect(formatUsageDuration(0)).toBe('0hr');
|
||||
expect(formatUsageDuration(3 * 60 * 60 * 1000)).toBe('3hr');
|
||||
expect(formatUsageDuration(3.5 * 60 * 60 * 1000)).toBe('3hr 30m');
|
||||
expect(formatUsageDuration(4 * 60 * 60 * 1000 + 5 * 60 * 1000)).toBe('4hr 5m');
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,7 @@ describe('widget catalog', () => {
|
||||
expect(categories).toContain('Context');
|
||||
expect(categories).toContain('Tokens');
|
||||
expect(categories).toContain('Session');
|
||||
expect(categories).toContain('Usage');
|
||||
expect(categories).toContain('Environment');
|
||||
expect(categories).toContain('Custom');
|
||||
expect(categories).toContain('Layout');
|
||||
@@ -127,4 +128,4 @@ describe('widget catalog filtering', () => {
|
||||
const results = filterWidgetCatalog(rankingCatalog, 'All', 'git');
|
||||
expect(results.map(entry => entry.type)).toEqual(['alpha', 'git-type-only', 'desc-only']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,24 @@
|
||||
import type { RenderContext } from '../types';
|
||||
|
||||
import { getContextWindowMetrics } from './context-window';
|
||||
import { getContextConfig } from './model-context';
|
||||
|
||||
/**
|
||||
* Calculate context window usage percentage based on model's max tokens
|
||||
*/
|
||||
export function calculateContextPercentage(context: RenderContext): number {
|
||||
const contextWindowMetrics = getContextWindowMetrics(context.data);
|
||||
if (contextWindowMetrics.usedPercentage !== null) {
|
||||
return contextWindowMetrics.usedPercentage;
|
||||
}
|
||||
|
||||
if (!context.tokenMetrics) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const model = context.data?.model;
|
||||
const modelId = typeof model === 'string' ? model : model?.id;
|
||||
const contextConfig = getContextConfig(modelId);
|
||||
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
|
||||
|
||||
return Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { StatusJSON } from '../types/StatusJSON';
|
||||
|
||||
export interface ContextWindowMetrics {
|
||||
windowSize: number | null;
|
||||
usedTokens: number | null;
|
||||
contextLengthTokens: number | null;
|
||||
usedPercentage: number | null;
|
||||
remainingPercentage: number | null;
|
||||
totalInputTokens: number | null;
|
||||
totalOutputTokens: number | null;
|
||||
cachedTokens: number | null;
|
||||
totalTokens: number | null;
|
||||
}
|
||||
|
||||
function toFiniteNonNegativeNumber(value: unknown): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.max(0, value);
|
||||
}
|
||||
|
||||
function clampPercentage(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
export function getContextWindowMetrics(data?: StatusJSON): ContextWindowMetrics {
|
||||
const contextWindow = data?.context_window;
|
||||
|
||||
if (!contextWindow) {
|
||||
return {
|
||||
windowSize: null,
|
||||
usedTokens: null,
|
||||
contextLengthTokens: null,
|
||||
usedPercentage: null,
|
||||
remainingPercentage: null,
|
||||
totalInputTokens: null,
|
||||
totalOutputTokens: null,
|
||||
cachedTokens: null,
|
||||
totalTokens: null
|
||||
};
|
||||
}
|
||||
|
||||
const rawWindowSize = toFiniteNonNegativeNumber(contextWindow.context_window_size);
|
||||
const windowSize = rawWindowSize !== null && rawWindowSize > 0 ? rawWindowSize : null;
|
||||
const totalInputTokens = toFiniteNonNegativeNumber(contextWindow.total_input_tokens);
|
||||
const totalOutputTokens = toFiniteNonNegativeNumber(contextWindow.total_output_tokens);
|
||||
|
||||
let currentUsageTotalTokens: number | null = null;
|
||||
let contextLengthTokens: number | null = null;
|
||||
let cachedTokens: number | null = null;
|
||||
|
||||
if (typeof contextWindow.current_usage === 'number') {
|
||||
currentUsageTotalTokens = toFiniteNonNegativeNumber(contextWindow.current_usage);
|
||||
contextLengthTokens = currentUsageTotalTokens;
|
||||
} else if (contextWindow.current_usage && typeof contextWindow.current_usage === 'object') {
|
||||
const usage = contextWindow.current_usage;
|
||||
const inputTokens = toFiniteNonNegativeNumber(usage.input_tokens) ?? 0;
|
||||
const outputTokens = toFiniteNonNegativeNumber(usage.output_tokens) ?? 0;
|
||||
const cacheCreationTokens = toFiniteNonNegativeNumber(usage.cache_creation_input_tokens) ?? 0;
|
||||
const cacheReadTokens = toFiniteNonNegativeNumber(usage.cache_read_input_tokens) ?? 0;
|
||||
|
||||
currentUsageTotalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
contextLengthTokens = inputTokens + cacheCreationTokens + cacheReadTokens;
|
||||
cachedTokens = cacheCreationTokens + cacheReadTokens;
|
||||
}
|
||||
|
||||
const rawUsedPercentage = toFiniteNonNegativeNumber(contextWindow.used_percentage);
|
||||
const rawRemainingPercentage = toFiniteNonNegativeNumber(contextWindow.remaining_percentage);
|
||||
const usedTokensFromPercentage = rawUsedPercentage !== null && windowSize !== null
|
||||
? (rawUsedPercentage / 100) * windowSize
|
||||
: null;
|
||||
|
||||
const usedTokens = currentUsageTotalTokens ?? usedTokensFromPercentage;
|
||||
|
||||
const usedPercentage = rawUsedPercentage !== null
|
||||
? clampPercentage(rawUsedPercentage)
|
||||
: usedTokens !== null && windowSize !== null && windowSize > 0
|
||||
? clampPercentage((usedTokens / windowSize) * 100)
|
||||
: null;
|
||||
|
||||
const remainingPercentage = rawRemainingPercentage !== null
|
||||
? clampPercentage(rawRemainingPercentage)
|
||||
: usedPercentage !== null
|
||||
? 100 - usedPercentage
|
||||
: null;
|
||||
|
||||
const totalTokens = currentUsageTotalTokens
|
||||
?? (totalInputTokens !== null && totalOutputTokens !== null
|
||||
? totalInputTokens + totalOutputTokens
|
||||
: null);
|
||||
|
||||
return {
|
||||
windowSize,
|
||||
usedTokens,
|
||||
contextLengthTokens: contextLengthTokens ?? usedTokens,
|
||||
usedPercentage,
|
||||
remainingPercentage,
|
||||
totalInputTokens,
|
||||
totalOutputTokens,
|
||||
cachedTokens,
|
||||
totalTokens
|
||||
};
|
||||
}
|
||||
|
||||
export function getContextWindowInputTotalTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).totalInputTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowOutputTotalTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).totalOutputTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowCachedTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).cachedTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowTotalTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).totalTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowContextLengthTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).contextLengthTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowUsedTokens(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).usedTokens;
|
||||
}
|
||||
|
||||
export function getContextWindowUsedPercentage(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).usedPercentage;
|
||||
}
|
||||
|
||||
export function getContextWindowSize(data?: StatusJSON): number | null {
|
||||
return getContextWindowMetrics(data).windowSize;
|
||||
}
|
||||
@@ -3,7 +3,23 @@ interface ModelContextConfig {
|
||||
usableTokens: number;
|
||||
}
|
||||
|
||||
export function getContextConfig(modelId?: string): ModelContextConfig {
|
||||
function toValidWindowSize(value: number | null | undefined): number | null {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getContextConfig(modelId?: string, contextWindowSize?: number | null): ModelContextConfig {
|
||||
const statusWindowSize = toValidWindowSize(contextWindowSize);
|
||||
if (statusWindowSize !== null) {
|
||||
return {
|
||||
maxTokens: statusWindowSize,
|
||||
usableTokens: Math.floor(statusWindowSize * 0.8)
|
||||
};
|
||||
}
|
||||
|
||||
// Default to 200k for older models
|
||||
const defaultConfig = {
|
||||
maxTokens: 200000,
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
execSync,
|
||||
spawnSync
|
||||
} from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import { getCachedBlockMetrics } from './jsonl';
|
||||
|
||||
// Cache configuration
|
||||
const CACHE_DIR = path.join(os.homedir(), '.cache', 'ccstatusline');
|
||||
const CACHE_FILE = path.join(CACHE_DIR, 'usage.json');
|
||||
const LOCK_FILE = path.join(CACHE_DIR, 'usage.lock');
|
||||
const CACHE_MAX_AGE = 180; // seconds
|
||||
const LOCK_MAX_AGE = 30; // rate limit: only try API once per 30 seconds
|
||||
const TOKEN_CACHE_MAX_AGE = 3600; // 1 hour
|
||||
export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000;
|
||||
|
||||
// Error types matching shell script
|
||||
const UsageErrorSchema = z.enum(['no-credentials', 'timeout', 'api-error', 'parse-error']);
|
||||
export type UsageError = z.infer<typeof UsageErrorSchema>;
|
||||
|
||||
export interface UsageData {
|
||||
sessionUsage?: number; // five_hour.utilization (percentage)
|
||||
sessionResetAt?: string; // five_hour.reset_at
|
||||
weeklyUsage?: number; // seven_day.utilization (percentage)
|
||||
extraUsageEnabled?: boolean;
|
||||
extraUsageLimit?: number; // in cents
|
||||
extraUsageUsed?: number; // in cents
|
||||
extraUsageUtilization?: number;
|
||||
error?: UsageError;
|
||||
}
|
||||
|
||||
export interface UsageWindowMetrics {
|
||||
sessionDurationMs: number;
|
||||
elapsedMs: number;
|
||||
remainingMs: number;
|
||||
elapsedPercent: number;
|
||||
remainingPercent: number;
|
||||
}
|
||||
|
||||
const UsageCredentialsSchema = z.object({ claudeAiOauth: z.object({ accessToken: z.string().nullable().optional() }).optional() });
|
||||
|
||||
const CachedUsageDataSchema = z.object({
|
||||
sessionUsage: z.number().nullable().optional(),
|
||||
sessionResetAt: z.string().nullable().optional(),
|
||||
weeklyUsage: z.number().nullable().optional(),
|
||||
extraUsageEnabled: z.boolean().nullable().optional(),
|
||||
extraUsageLimit: z.number().nullable().optional(),
|
||||
extraUsageUsed: z.number().nullable().optional(),
|
||||
extraUsageUtilization: z.number().nullable().optional(),
|
||||
error: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
const UsageApiResponseSchema = z.object({
|
||||
five_hour: z.object({
|
||||
utilization: z.number().nullable().optional(),
|
||||
reset_at: z.string().nullable().optional(),
|
||||
resets_at: z.string().nullable().optional()
|
||||
}).optional(),
|
||||
seven_day: z.object({ utilization: z.number().nullable().optional() }).optional(),
|
||||
extra_usage: z.object({
|
||||
is_enabled: z.boolean().nullable().optional(),
|
||||
monthly_limit: z.number().nullable().optional(),
|
||||
used_credits: z.number().nullable().optional(),
|
||||
utilization: z.number().nullable().optional()
|
||||
}).optional()
|
||||
});
|
||||
|
||||
function parseJsonWithSchema<T>(rawJson: string, schema: z.ZodType<T>): T | null {
|
||||
try {
|
||||
const parsed = schema.safeParse(JSON.parse(rawJson));
|
||||
return parsed.success ? parsed.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsageAccessToken(rawJson: string): string | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, UsageCredentialsSchema);
|
||||
return parsed?.claudeAiOauth?.accessToken ?? null;
|
||||
}
|
||||
|
||||
function parseCachedUsageData(rawJson: string): UsageData | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, CachedUsageDataSchema);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedError = UsageErrorSchema.safeParse(parsed.error);
|
||||
|
||||
return {
|
||||
sessionUsage: parsed.sessionUsage ?? undefined,
|
||||
sessionResetAt: parsed.sessionResetAt ?? undefined,
|
||||
weeklyUsage: parsed.weeklyUsage ?? undefined,
|
||||
extraUsageEnabled: parsed.extraUsageEnabled ?? undefined,
|
||||
extraUsageLimit: parsed.extraUsageLimit ?? undefined,
|
||||
extraUsageUsed: parsed.extraUsageUsed ?? undefined,
|
||||
extraUsageUtilization: parsed.extraUsageUtilization ?? undefined,
|
||||
error: parsedError.success ? parsedError.data : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function parseUsageApiResponse(rawJson: string): UsageData | null {
|
||||
const parsed = parseJsonWithSchema(rawJson, UsageApiResponseSchema);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionUsage: parsed.five_hour?.utilization ?? undefined,
|
||||
sessionResetAt: parsed.five_hour?.resets_at ?? parsed.five_hour?.reset_at ?? undefined,
|
||||
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
|
||||
extraUsageEnabled: parsed.extra_usage?.is_enabled ?? undefined,
|
||||
extraUsageLimit: parsed.extra_usage?.monthly_limit ?? undefined,
|
||||
extraUsageUsed: parsed.extra_usage?.used_credits ?? undefined,
|
||||
extraUsageUtilization: parsed.extra_usage?.utilization ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
// Memory caches
|
||||
let cachedUsageData: UsageData | null = null;
|
||||
let usageCacheTime = 0;
|
||||
let cachedUsageToken: string | null = null;
|
||||
let usageTokenCacheTime = 0;
|
||||
|
||||
function setCachedUsageError(error: UsageError, now: number): UsageData {
|
||||
const errorData: UsageData = { error };
|
||||
cachedUsageData = errorData;
|
||||
usageCacheTime = now;
|
||||
return errorData;
|
||||
}
|
||||
|
||||
function getStaleUsageOrError(error: UsageError, now: number): UsageData {
|
||||
const stale = readStaleUsageCache();
|
||||
if (stale && !stale.error) {
|
||||
cachedUsageData = stale;
|
||||
usageCacheTime = now;
|
||||
return stale;
|
||||
}
|
||||
return setCachedUsageError(error, now);
|
||||
}
|
||||
|
||||
function getUsageToken(): string | null {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Return cached token if still valid
|
||||
if (cachedUsageToken && (now - usageTokenCacheTime) < TOKEN_CACHE_MAX_AGE) {
|
||||
return cachedUsageToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const isMac = process.platform === 'darwin';
|
||||
if (isMac) {
|
||||
// macOS: read from keychain
|
||||
const result = execSync(
|
||||
'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null',
|
||||
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim();
|
||||
const token = parseUsageAccessToken(result);
|
||||
if (token) {
|
||||
cachedUsageToken = token;
|
||||
usageTokenCacheTime = now;
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
// Non-macOS: read from credentials file, honoring CLAUDE_CONFIG_DIR
|
||||
const credFile = path.join(getClaudeConfigDir(), '.credentials.json');
|
||||
const token = parseUsageAccessToken(fs.readFileSync(credFile, 'utf8'));
|
||||
if (token) {
|
||||
cachedUsageToken = token;
|
||||
usageTokenCacheTime = now;
|
||||
}
|
||||
return token;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readStaleUsageCache(): UsageData | null {
|
||||
try {
|
||||
return parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFromUsageApi(token: string): string | null {
|
||||
// Use the active runtime to make a synchronous API call via spawnSync.
|
||||
const script = `
|
||||
const token = process.env.TOKEN;
|
||||
if (!token) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const https = require('https');
|
||||
const options = {
|
||||
hostname: 'api.anthropic.com',
|
||||
path: '/api/oauth/usage',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'anthropic-beta': 'oauth-2025-04-20'
|
||||
},
|
||||
timeout: 5000
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200 && data) {
|
||||
process.stdout.write(data);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => process.exit(1));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
req.end();
|
||||
`;
|
||||
|
||||
const runtimePath = process.execPath || 'node';
|
||||
const result = spawnSync(runtimePath, ['-e', script], {
|
||||
encoding: 'utf8',
|
||||
timeout: 6000,
|
||||
env: { ...process.env, TOKEN: token }
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0 || !result.stdout) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export function fetchUsageData(): UsageData {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Check memory cache (fast path)
|
||||
if (cachedUsageData) {
|
||||
const cacheAge = now - usageCacheTime;
|
||||
if (!cachedUsageData.error && cacheAge < CACHE_MAX_AGE) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
if (cachedUsageData.error && cacheAge < LOCK_MAX_AGE) {
|
||||
return cachedUsageData;
|
||||
}
|
||||
}
|
||||
|
||||
// Check file cache
|
||||
try {
|
||||
const stat = fs.statSync(CACHE_FILE);
|
||||
const fileAge = now - Math.floor(stat.mtimeMs / 1000);
|
||||
if (fileAge < CACHE_MAX_AGE) {
|
||||
const fileData = parseCachedUsageData(fs.readFileSync(CACHE_FILE, 'utf8'));
|
||||
if (fileData && !fileData.error) {
|
||||
cachedUsageData = fileData;
|
||||
usageCacheTime = now;
|
||||
return fileData;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist or read error - continue to API call
|
||||
}
|
||||
|
||||
// Get token before lock/rate-limit checks so auth failures are not masked as timeout.
|
||||
const token = getUsageToken();
|
||||
if (!token) {
|
||||
return getStaleUsageOrError('no-credentials', now);
|
||||
}
|
||||
|
||||
// Rate limit: only try API once per 30 seconds
|
||||
try {
|
||||
const lockStat = fs.statSync(LOCK_FILE);
|
||||
const lockAge = now - Math.floor(lockStat.mtimeMs / 1000);
|
||||
if (lockAge < LOCK_MAX_AGE) {
|
||||
// Rate limited - return stale cache or timeout error
|
||||
const stale = readStaleUsageCache();
|
||||
if (stale && !stale.error)
|
||||
return stale;
|
||||
return { error: 'timeout' };
|
||||
}
|
||||
} catch {
|
||||
// Lock file doesn't exist - OK to proceed
|
||||
}
|
||||
|
||||
// Touch lock file
|
||||
try {
|
||||
const lockDir = path.dirname(LOCK_FILE);
|
||||
if (!fs.existsSync(lockDir)) {
|
||||
fs.mkdirSync(lockDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(LOCK_FILE, '');
|
||||
} catch {
|
||||
// Ignore lock file errors
|
||||
}
|
||||
|
||||
// Fetch from API using Node's https module
|
||||
try {
|
||||
const response = fetchFromUsageApi(token);
|
||||
|
||||
if (!response) {
|
||||
return getStaleUsageOrError('api-error', now);
|
||||
}
|
||||
|
||||
const usageData = parseUsageApiResponse(response);
|
||||
if (!usageData) {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
|
||||
// Validate we got actual data
|
||||
if (usageData.sessionUsage === undefined && usageData.weeklyUsage === undefined) {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
|
||||
// Save to cache
|
||||
try {
|
||||
if (!fs.existsSync(CACHE_DIR)) {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(usageData));
|
||||
} catch {
|
||||
// Ignore cache write errors
|
||||
}
|
||||
|
||||
cachedUsageData = usageData;
|
||||
usageCacheTime = now;
|
||||
return usageData;
|
||||
} catch {
|
||||
return getStaleUsageOrError('parse-error', now);
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function buildUsageWindow(resetAtMs: number, nowMs: number): UsageWindowMetrics | null {
|
||||
if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startAtMs = resetAtMs - FIVE_HOUR_BLOCK_MS;
|
||||
const elapsedMs = clamp(nowMs - startAtMs, 0, FIVE_HOUR_BLOCK_MS);
|
||||
const remainingMs = FIVE_HOUR_BLOCK_MS - elapsedMs;
|
||||
const elapsedPercent = (elapsedMs / FIVE_HOUR_BLOCK_MS) * 100;
|
||||
|
||||
return {
|
||||
sessionDurationMs: FIVE_HOUR_BLOCK_MS,
|
||||
elapsedMs,
|
||||
remainingMs,
|
||||
elapsedPercent,
|
||||
remainingPercent: 100 - elapsedPercent
|
||||
};
|
||||
}
|
||||
|
||||
export function getUsageWindowFromResetAt(sessionResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
if (!sessionResetAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetAtMs = Date.parse(sessionResetAt);
|
||||
if (Number.isNaN(resetAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(resetAtMs, nowMs);
|
||||
}
|
||||
|
||||
export function getUsageWindowFromBlockMetrics(blockMetrics: BlockMetrics, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
const startAtMs = blockMetrics.startTime.getTime();
|
||||
if (Number.isNaN(startAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(startAtMs + FIVE_HOUR_BLOCK_MS, nowMs);
|
||||
}
|
||||
|
||||
export function resolveUsageWindowWithFallback(
|
||||
usageData: UsageData,
|
||||
blockMetrics?: BlockMetrics | null,
|
||||
nowMs = Date.now()
|
||||
): UsageWindowMetrics | null {
|
||||
const usageWindow = getUsageWindowFromResetAt(usageData.sessionResetAt, nowMs);
|
||||
if (usageWindow) {
|
||||
return usageWindow;
|
||||
}
|
||||
|
||||
const fallbackMetrics = blockMetrics ?? getCachedBlockMetrics();
|
||||
if (!fallbackMetrics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getUsageWindowFromBlockMetrics(fallbackMetrics, nowMs);
|
||||
}
|
||||
|
||||
export function formatUsageDuration(durationMs: number): string {
|
||||
const clampedMs = Math.max(0, durationMs);
|
||||
const elapsedHours = Math.floor(clampedMs / (1000 * 60 * 60));
|
||||
const elapsedMinutes = Math.floor((clampedMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (elapsedMinutes === 0) {
|
||||
return `${elapsedHours}hr`;
|
||||
}
|
||||
|
||||
return `${elapsedHours}hr ${elapsedMinutes}m`;
|
||||
}
|
||||
|
||||
export function getUsageErrorMessage(error: UsageError): string {
|
||||
switch (error) {
|
||||
case 'no-credentials': return '[No credentials]';
|
||||
case 'timeout': return '[Timeout]';
|
||||
case 'api-error': return '[API Error]';
|
||||
case 'parse-error': return '[Parse Error]';
|
||||
}
|
||||
}
|
||||
|
||||
export function makeUsageProgressBar(percent: number, width = 15): string {
|
||||
const filled = Math.round((percent / 100) * width);
|
||||
const empty = width - filled;
|
||||
return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']';
|
||||
}
|
||||
@@ -30,7 +30,11 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
|
||||
['custom-command', new widgets.CustomCommandWidget()],
|
||||
['claude-session-id', new widgets.ClaudeSessionIdWidget()],
|
||||
['session-name', new widgets.SessionNameWidget()],
|
||||
['free-memory', new widgets.FreeMemoryWidget()]
|
||||
['free-memory', new widgets.FreeMemoryWidget()],
|
||||
['session-usage', new widgets.SessionUsageWidget()],
|
||||
['weekly-usage', new widgets.WeeklyUsageWidget()],
|
||||
['reset-timer', new widgets.ResetTimerWidget()],
|
||||
['context-bar', new widgets.ContextBarWidget()]
|
||||
]);
|
||||
|
||||
export function getWidget(type: WidgetItemType): Widget | null {
|
||||
|
||||
+81
-52
@@ -6,17 +6,41 @@ import type {
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../utils/usage';
|
||||
|
||||
type DisplayMode = 'time' | 'progress' | 'progress-short';
|
||||
|
||||
function getDisplayMode(item: WidgetItem): DisplayMode {
|
||||
const mode = item.metadata?.display;
|
||||
if (mode === 'progress' || mode === 'progress-short') {
|
||||
return mode;
|
||||
}
|
||||
return 'time';
|
||||
}
|
||||
|
||||
function isInverted(item: WidgetItem): boolean {
|
||||
return item.metadata?.invert === 'true';
|
||||
}
|
||||
|
||||
function makeTimerProgressBar(percent: number, width: number): string {
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
const filledWidth = Math.floor((clampedPercent / 100) * width);
|
||||
const emptyWidth = width - filledWidth;
|
||||
return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth);
|
||||
}
|
||||
|
||||
export class BlockTimerWidget implements Widget {
|
||||
getDefaultColor(): string { return 'yellow'; }
|
||||
getDescription(): string { return 'Shows elapsed time since beginning of current 5hr block'; }
|
||||
getDescription(): string { return 'Shows current 5hr block elapsed time or progress'; }
|
||||
getDisplayName(): string { return 'Block Timer'; }
|
||||
getCategory(): string { return 'Session'; }
|
||||
getCategory(): string { return 'Usage'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const mode = item.metadata?.display ?? 'time';
|
||||
const mode = getDisplayMode(item);
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (mode === 'progress') {
|
||||
@@ -25,6 +49,10 @@ export class BlockTimerWidget implements Widget {
|
||||
modifiers.push('short bar');
|
||||
}
|
||||
|
||||
if (isInverted(item)) {
|
||||
modifiers.push('inverted');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
||||
@@ -33,7 +61,7 @@ export class BlockTimerWidget implements Widget {
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
const currentMode = (item.metadata?.display ?? 'time') as DisplayMode;
|
||||
const currentMode = getDisplayMode(item);
|
||||
let nextMode: DisplayMode;
|
||||
|
||||
if (currentMode === 'time') {
|
||||
@@ -44,87 +72,88 @@ export class BlockTimerWidget implements Widget {
|
||||
nextMode = 'time';
|
||||
}
|
||||
|
||||
const nextMetadata: Record<string, string> = {
|
||||
...(item.metadata ?? {}),
|
||||
display: nextMode
|
||||
};
|
||||
|
||||
if (nextMode === 'time') {
|
||||
delete nextMetadata.invert;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: nextMetadata
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
display: nextMode
|
||||
invert: (!isInverted(item)).toString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const displayMode = (item.metadata?.display ?? 'time') as DisplayMode;
|
||||
const displayMode = getDisplayMode(item);
|
||||
const inverted = isInverted(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewPercent = inverted ? 26.1 : 73.9;
|
||||
const prefix = item.rawValue ? '' : 'Block ';
|
||||
if (displayMode === 'progress') {
|
||||
return `${prefix}[██████████████████████░░░░░░░░] 73.9%`;
|
||||
} else if (displayMode === 'progress-short') {
|
||||
return `${prefix}[███████░░░░░░░░] 73.9%`;
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
const progressBar = makeTimerProgressBar(previewPercent, barWidth);
|
||||
return `${prefix}[${progressBar}] ${previewPercent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
return item.rawValue ? '3hr 45m' : 'Block: 3hr 45m';
|
||||
}
|
||||
|
||||
// Check if we have block metrics in context
|
||||
const blockMetrics = context.blockMetrics;
|
||||
if (!blockMetrics) {
|
||||
// No active session - show empty progress bar or 0hr 0m
|
||||
const usageData = fetchUsageData();
|
||||
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
|
||||
|
||||
if (!window) {
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
const emptyBar = '░'.repeat(barWidth);
|
||||
return item.rawValue ? `[${emptyBar}] 0%` : `Block [${emptyBar}] 0%`;
|
||||
} else {
|
||||
return item.rawValue ? '0hr 0m' : 'Block: 0hr 0m';
|
||||
return item.rawValue ? `[${emptyBar}] 0.0%` : `Block [${emptyBar}] 0.0%`;
|
||||
}
|
||||
|
||||
return item.rawValue ? '0hr 0m' : 'Block: 0hr 0m';
|
||||
}
|
||||
|
||||
try {
|
||||
// Calculate elapsed time and progress
|
||||
const now = new Date();
|
||||
const elapsedMs = now.getTime() - blockMetrics.startTime.getTime();
|
||||
const sessionDurationMs = 5 * 60 * 60 * 1000; // 5 hours
|
||||
const progress = Math.min(elapsedMs / sessionDurationMs, 1.0);
|
||||
const percentage = (progress * 100).toFixed(1);
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
const percent = inverted ? window.remainingPercent : window.elapsedPercent;
|
||||
const progressBar = makeTimerProgressBar(percent, barWidth);
|
||||
const percentage = percent.toFixed(1);
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
const filledWidth = Math.floor(progress * barWidth);
|
||||
const emptyWidth = barWidth - filledWidth;
|
||||
const progressBar = '█'.repeat(filledWidth) + '░'.repeat(emptyWidth);
|
||||
|
||||
if (item.rawValue) {
|
||||
return `[${progressBar}] ${percentage}%`;
|
||||
} else {
|
||||
return `Block [${progressBar}] ${percentage}%`;
|
||||
}
|
||||
} else {
|
||||
// Time display mode
|
||||
const elapsedHours = Math.floor(elapsedMs / (1000 * 60 * 60));
|
||||
const elapsedMinutes = Math.floor((elapsedMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
let timeString: string;
|
||||
if (elapsedMinutes === 0) {
|
||||
timeString = `${elapsedHours}hr`;
|
||||
} else {
|
||||
timeString = `${elapsedHours}hr ${elapsedMinutes}m`;
|
||||
}
|
||||
|
||||
return item.rawValue ? timeString : `Block: ${timeString}`;
|
||||
if (item.rawValue) {
|
||||
return `[${progressBar}] ${percentage}%`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
|
||||
return `Block [${progressBar}] ${percentage}%`;
|
||||
}
|
||||
|
||||
const elapsedTime = formatUsageDuration(window.elapsedMs);
|
||||
return item.rawValue ? elapsedTime : `Block: ${elapsedTime}`;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { getContextWindowMetrics } from '../utils/context-window';
|
||||
import { getContextConfig } from '../utils/model-context';
|
||||
import { makeUsageProgressBar } from '../utils/usage';
|
||||
|
||||
type DisplayMode = 'progress' | 'progress-short';
|
||||
|
||||
function getDisplayMode(item: WidgetItem): DisplayMode {
|
||||
return item.metadata?.display === 'progress' ? 'progress' : 'progress-short';
|
||||
}
|
||||
|
||||
export class ContextBarWidget implements Widget {
|
||||
getDefaultColor(): string { return 'blue'; }
|
||||
getDescription(): string { return 'Shows context usage as a progress bar'; }
|
||||
getDisplayName(): string { return 'Context Bar'; }
|
||||
getCategory(): string { return 'Context'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const mode = getDisplayMode(item);
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (mode === 'progress-short') {
|
||||
modifiers.push('short bar');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action !== 'toggle-progress') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentMode = getDisplayMode(item);
|
||||
const nextMode: DisplayMode = currentMode === 'progress-short' ? 'progress' : 'progress-short';
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...(item.metadata ?? {}),
|
||||
display: nextMode
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const displayMode = getDisplayMode(item);
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewDisplay = `${makeUsageProgressBar(25, barWidth)} 50k/200k (25%)`;
|
||||
return item.rawValue ? previewDisplay : `Context: ${previewDisplay}`;
|
||||
}
|
||||
|
||||
const contextWindowMetrics = getContextWindowMetrics(context.data);
|
||||
|
||||
let total = contextWindowMetrics.windowSize;
|
||||
let used = contextWindowMetrics.contextLengthTokens;
|
||||
|
||||
if (used === null && context.tokenMetrics) {
|
||||
used = context.tokenMetrics.contextLength;
|
||||
}
|
||||
|
||||
if (total === null && context.tokenMetrics) {
|
||||
const model = context.data?.model;
|
||||
const modelId = typeof model === 'string' ? model : model?.id;
|
||||
total = getContextConfig(modelId).maxTokens;
|
||||
}
|
||||
|
||||
if (used === null || total === null || total <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percent = (used / total) * 100;
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
|
||||
const usedK = Math.round(used / 1000);
|
||||
const totalK = Math.round(total / 1000);
|
||||
const display = `${makeUsageProgressBar(clampedPercent, barWidth)} ${usedK}k/${totalK}k (${Math.round(clampedPercent)}%)`;
|
||||
|
||||
return item.rawValue ? display : `Context: ${display}`;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { getContextWindowContextLengthTokens } from '../utils/context-window';
|
||||
import { formatTokens } from '../utils/renderer';
|
||||
|
||||
export class ContextLengthWidget implements Widget {
|
||||
@@ -19,7 +20,14 @@ export class ContextLengthWidget implements Widget {
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
if (context.isPreview) {
|
||||
return item.rawValue ? '18.6k' : 'Ctx: 18.6k';
|
||||
} else if (context.tokenMetrics) {
|
||||
}
|
||||
|
||||
const contextLengthTokens = getContextWindowContextLengthTokens(context.data);
|
||||
if (contextLengthTokens !== null) {
|
||||
return item.rawValue ? formatTokens(contextLengthTokens) : `Ctx: ${formatTokens(contextLengthTokens)}`;
|
||||
}
|
||||
|
||||
if (context.tokenMetrics) {
|
||||
return item.rawValue ? formatTokens(context.tokenMetrics.contextLength) : `Ctx: ${formatTokens(context.tokenMetrics.contextLength)}`;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { getContextWindowMetrics } from '../utils/context-window';
|
||||
import { getContextConfig } from '../utils/model-context';
|
||||
|
||||
export class ContextPercentageWidget implements Widget {
|
||||
@@ -43,27 +44,36 @@ export class ContextPercentageWidget implements Widget {
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const isInverse = item.metadata?.inverse === 'true';
|
||||
const contextWindowMetrics = getContextWindowMetrics(context.data);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewValue = isInverse ? '90.7%' : '9.3%';
|
||||
return item.rawValue ? previewValue : `Ctx: ${previewValue}`;
|
||||
} else if (context.tokenMetrics) {
|
||||
}
|
||||
|
||||
if (contextWindowMetrics.usedPercentage !== null) {
|
||||
const displayPercentage = isInverse ? (100 - contextWindowMetrics.usedPercentage) : contextWindowMetrics.usedPercentage;
|
||||
return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx: ${displayPercentage.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
if (context.tokenMetrics) {
|
||||
const model = context.data?.model;
|
||||
const modelId = typeof model === 'string' ? model : model?.id;
|
||||
const contextConfig = getContextConfig(modelId);
|
||||
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
|
||||
const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.maxTokens) * 100);
|
||||
const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage;
|
||||
return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx: ${displayPercentage.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' }
|
||||
{ key: 'u', label: '(u)sed/remaining', action: 'toggle-inverse' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { getContextWindowMetrics } from '../utils/context-window';
|
||||
import { getContextConfig } from '../utils/model-context';
|
||||
|
||||
export class ContextPercentageUsableWidget implements Widget {
|
||||
@@ -43,14 +44,23 @@ export class ContextPercentageUsableWidget implements Widget {
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const isInverse = item.metadata?.inverse === 'true';
|
||||
const model = context.data?.model;
|
||||
const modelId = typeof model === 'string' ? model : model?.id;
|
||||
const contextWindowMetrics = getContextWindowMetrics(context.data);
|
||||
const contextConfig = getContextConfig(modelId, contextWindowMetrics.windowSize);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewValue = isInverse ? '88.4%' : '11.6%';
|
||||
return item.rawValue ? previewValue : `Ctx(u): ${previewValue}`;
|
||||
} else if (context.tokenMetrics) {
|
||||
const model = context.data?.model;
|
||||
const modelId = typeof model === 'string' ? model : model?.id;
|
||||
const contextConfig = getContextConfig(modelId);
|
||||
}
|
||||
|
||||
if (contextWindowMetrics.contextLengthTokens !== null) {
|
||||
const usedPercentage = Math.min(100, (contextWindowMetrics.contextLengthTokens / contextConfig.usableTokens) * 100);
|
||||
const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage;
|
||||
return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx(u): ${displayPercentage.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
if (context.tokenMetrics) {
|
||||
const usedPercentage = Math.min(100, (context.tokenMetrics.contextLength / contextConfig.usableTokens) * 100);
|
||||
const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage;
|
||||
return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx(u): ${displayPercentage.toFixed(1)}%`;
|
||||
@@ -60,10 +70,10 @@ export class ContextPercentageUsableWidget implements Widget {
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'l', label: '(l)eft/remaining', action: 'toggle-inverse' }
|
||||
{ key: 'u', label: '(u)sed/remaining', action: 'toggle-inverse' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../utils/usage';
|
||||
|
||||
type DisplayMode = 'time' | 'progress' | 'progress-short';
|
||||
|
||||
function getDisplayMode(item: WidgetItem): DisplayMode {
|
||||
const mode = item.metadata?.display;
|
||||
if (mode === 'progress' || mode === 'progress-short') {
|
||||
return mode;
|
||||
}
|
||||
return 'time';
|
||||
}
|
||||
|
||||
function isInverted(item: WidgetItem): boolean {
|
||||
return item.metadata?.invert === 'true';
|
||||
}
|
||||
|
||||
function makeTimerProgressBar(percent: number, width: number): string {
|
||||
const clampedPercent = Math.max(0, Math.min(100, percent));
|
||||
const filledWidth = Math.floor((clampedPercent / 100) * width);
|
||||
const emptyWidth = width - filledWidth;
|
||||
return '█'.repeat(filledWidth) + '░'.repeat(emptyWidth);
|
||||
}
|
||||
|
||||
export class ResetTimerWidget implements Widget {
|
||||
getDefaultColor(): string { return 'brightBlue'; }
|
||||
getDescription(): string { return 'Shows time remaining until current 5hr block reset'; }
|
||||
getDisplayName(): string { return 'Reset Timer'; }
|
||||
getCategory(): string { return 'Usage'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const mode = getDisplayMode(item);
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (mode === 'progress') {
|
||||
modifiers.push('progress bar');
|
||||
} else if (mode === 'progress-short') {
|
||||
modifiers.push('short bar');
|
||||
}
|
||||
|
||||
if (isInverted(item)) {
|
||||
modifiers.push('inverted');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
const currentMode = getDisplayMode(item);
|
||||
let nextMode: DisplayMode;
|
||||
|
||||
if (currentMode === 'time') {
|
||||
nextMode = 'progress';
|
||||
} else if (currentMode === 'progress') {
|
||||
nextMode = 'progress-short';
|
||||
} else {
|
||||
nextMode = 'time';
|
||||
}
|
||||
|
||||
const nextMetadata: Record<string, string> = {
|
||||
...(item.metadata ?? {}),
|
||||
display: nextMode
|
||||
};
|
||||
|
||||
if (nextMode === 'time') {
|
||||
delete nextMetadata.invert;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: nextMetadata
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
invert: (!isInverted(item)).toString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const displayMode = getDisplayMode(item);
|
||||
const inverted = isInverted(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewPercent = inverted ? 90.0 : 10.0;
|
||||
const prefix = item.rawValue ? '' : 'Reset ';
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
const progressBar = makeTimerProgressBar(previewPercent, barWidth);
|
||||
return `${prefix}[${progressBar}] ${previewPercent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
return item.rawValue ? '4hr 30m' : 'Reset: 4hr 30m';
|
||||
}
|
||||
|
||||
const usageData = fetchUsageData();
|
||||
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
|
||||
|
||||
if (!window) {
|
||||
if (usageData.error) {
|
||||
return getUsageErrorMessage(usageData.error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const barWidth = displayMode === 'progress' ? 32 : 16;
|
||||
const percent = inverted ? window.remainingPercent : window.elapsedPercent;
|
||||
const progressBar = makeTimerProgressBar(percent, barWidth);
|
||||
const percentage = percent.toFixed(1);
|
||||
|
||||
if (item.rawValue) {
|
||||
return `[${progressBar}] ${percentage}%`;
|
||||
}
|
||||
|
||||
return `Reset [${progressBar}] ${percentage}%`;
|
||||
}
|
||||
|
||||
const remainingTime = formatUsageDuration(window.remainingMs);
|
||||
return item.rawValue ? remainingTime : `Reset: ${remainingTime}`;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -6,6 +6,26 @@ import type {
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
|
||||
function formatDurationFromMs(durationMs: number): string {
|
||||
const totalMinutes = Math.floor(durationMs / (1000 * 60));
|
||||
|
||||
if (totalMinutes < 1) {
|
||||
return '<1m';
|
||||
}
|
||||
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
|
||||
if (hours === 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
if (minutes === 0) {
|
||||
return `${hours}hr`;
|
||||
}
|
||||
|
||||
return `${hours}hr ${minutes}m`;
|
||||
}
|
||||
|
||||
export class SessionClockWidget implements Widget {
|
||||
getDefaultColor(): string { return 'yellow'; }
|
||||
getDescription(): string { return 'Shows elapsed time since current session started'; }
|
||||
@@ -20,6 +40,12 @@ export class SessionClockWidget implements Widget {
|
||||
return item.rawValue ? '2hr 15m' : 'Session: 2hr 15m';
|
||||
}
|
||||
|
||||
const durationMs = context.data?.cost?.total_duration_ms;
|
||||
if (typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0) {
|
||||
const formatted = formatDurationFromMs(durationMs);
|
||||
return item.rawValue ? formatted : `Session: ${formatted}`;
|
||||
}
|
||||
|
||||
const duration = context.sessionDuration ?? '0m';
|
||||
return item.rawValue ? duration : `Session: ${duration}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
getUsageErrorMessage,
|
||||
makeUsageProgressBar
|
||||
} from '../utils/usage';
|
||||
|
||||
type DisplayMode = 'time' | 'progress' | 'progress-short';
|
||||
|
||||
function getDisplayMode(item: WidgetItem): DisplayMode {
|
||||
const mode = item.metadata?.display;
|
||||
if (mode === 'progress' || mode === 'progress-short') {
|
||||
return mode;
|
||||
}
|
||||
return 'time';
|
||||
}
|
||||
|
||||
function isInverted(item: WidgetItem): boolean {
|
||||
return item.metadata?.invert === 'true';
|
||||
}
|
||||
|
||||
export class SessionUsageWidget implements Widget {
|
||||
getDefaultColor(): string { return 'brightBlue'; }
|
||||
getDescription(): string { return 'Shows daily/session API usage percentage'; }
|
||||
getDisplayName(): string { return 'Session Usage'; }
|
||||
getCategory(): string { return 'Usage'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const mode = getDisplayMode(item);
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (mode === 'progress') {
|
||||
modifiers.push('progress bar');
|
||||
} else if (mode === 'progress-short') {
|
||||
modifiers.push('short bar');
|
||||
}
|
||||
|
||||
if (isInverted(item)) {
|
||||
modifiers.push('inverted');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
const currentMode = getDisplayMode(item);
|
||||
let nextMode: DisplayMode;
|
||||
|
||||
if (currentMode === 'time') {
|
||||
nextMode = 'progress';
|
||||
} else if (currentMode === 'progress') {
|
||||
nextMode = 'progress-short';
|
||||
} else {
|
||||
nextMode = 'time';
|
||||
}
|
||||
|
||||
const nextMetadata: Record<string, string> = {
|
||||
...(item.metadata ?? {}),
|
||||
display: nextMode
|
||||
};
|
||||
|
||||
if (nextMode === 'time') {
|
||||
delete nextMetadata.invert;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: nextMetadata
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
invert: (!isInverted(item)).toString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const displayMode = getDisplayMode(item);
|
||||
const inverted = isInverted(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewPercent = 20;
|
||||
const renderedPercent = inverted ? 100 - previewPercent : previewPercent;
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const width = displayMode === 'progress' ? 32 : 16;
|
||||
const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`;
|
||||
return item.rawValue ? progressDisplay : `Session: ${progressDisplay}`;
|
||||
}
|
||||
|
||||
return item.rawValue ? `${previewPercent.toFixed(1)}%` : `Session: ${previewPercent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const data = fetchUsageData();
|
||||
if (data.error)
|
||||
return getUsageErrorMessage(data.error);
|
||||
if (data.sessionUsage === undefined)
|
||||
return null;
|
||||
|
||||
const percent = Math.max(0, Math.min(100, data.sessionUsage));
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const width = displayMode === 'progress' ? 32 : 16;
|
||||
const renderedPercent = inverted ? 100 - percent : percent;
|
||||
const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`;
|
||||
return item.rawValue ? progressDisplay : `Session: ${progressDisplay}`;
|
||||
}
|
||||
|
||||
return item.rawValue ? `${percent.toFixed(1)}%` : `Session: ${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -19,7 +19,9 @@ export class TokensCachedWidget implements Widget {
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
if (context.isPreview) {
|
||||
return item.rawValue ? '12k' : 'Cached: 12k';
|
||||
} else if (context.tokenMetrics) {
|
||||
}
|
||||
|
||||
if (context.tokenMetrics) {
|
||||
return item.rawValue ? formatTokens(context.tokenMetrics.cachedTokens) : `Cached: ${formatTokens(context.tokenMetrics.cachedTokens)}`;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { getContextWindowInputTotalTokens } from '../utils/context-window';
|
||||
import { formatTokens } from '../utils/renderer';
|
||||
|
||||
export class TokensInputWidget implements Widget {
|
||||
@@ -19,7 +20,14 @@ export class TokensInputWidget implements Widget {
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
if (context.isPreview) {
|
||||
return item.rawValue ? '15.2k' : 'In: 15.2k';
|
||||
} else if (context.tokenMetrics) {
|
||||
}
|
||||
|
||||
const inputTotalTokens = getContextWindowInputTotalTokens(context.data);
|
||||
if (inputTotalTokens !== null) {
|
||||
return item.rawValue ? formatTokens(inputTotalTokens) : `In: ${formatTokens(inputTotalTokens)}`;
|
||||
}
|
||||
|
||||
if (context.tokenMetrics) {
|
||||
return item.rawValue ? formatTokens(context.tokenMetrics.inputTokens) : `In: ${formatTokens(context.tokenMetrics.inputTokens)}`;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import { getContextWindowOutputTotalTokens } from '../utils/context-window';
|
||||
import { formatTokens } from '../utils/renderer';
|
||||
|
||||
export class TokensOutputWidget implements Widget {
|
||||
@@ -19,7 +20,14 @@ export class TokensOutputWidget implements Widget {
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
if (context.isPreview) {
|
||||
return item.rawValue ? '3.4k' : 'Out: 3.4k';
|
||||
} else if (context.tokenMetrics) {
|
||||
}
|
||||
|
||||
const outputTotalTokens = getContextWindowOutputTotalTokens(context.data);
|
||||
if (outputTotalTokens !== null) {
|
||||
return item.rawValue ? formatTokens(outputTotalTokens) : `Out: ${formatTokens(outputTotalTokens)}`;
|
||||
}
|
||||
|
||||
if (context.tokenMetrics) {
|
||||
return item.rawValue ? formatTokens(context.tokenMetrics.outputTokens) : `Out: ${formatTokens(context.tokenMetrics.outputTokens)}`;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -19,7 +19,9 @@ export class TokensTotalWidget implements Widget {
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
if (context.isPreview) {
|
||||
return item.rawValue ? '30.6k' : 'Total: 30.6k';
|
||||
} else if (context.tokenMetrics) {
|
||||
}
|
||||
|
||||
if (context.tokenMetrics) {
|
||||
return item.rawValue ? formatTokens(context.tokenMetrics.totalTokens) : `Total: ${formatTokens(context.tokenMetrics.totalTokens)}`;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { RenderContext } from '../types/RenderContext';
|
||||
import type { Settings } from '../types/Settings';
|
||||
import type {
|
||||
CustomKeybind,
|
||||
Widget,
|
||||
WidgetEditorDisplay,
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
getUsageErrorMessage,
|
||||
makeUsageProgressBar
|
||||
} from '../utils/usage';
|
||||
|
||||
type DisplayMode = 'time' | 'progress' | 'progress-short';
|
||||
|
||||
function getDisplayMode(item: WidgetItem): DisplayMode {
|
||||
const mode = item.metadata?.display;
|
||||
if (mode === 'progress' || mode === 'progress-short') {
|
||||
return mode;
|
||||
}
|
||||
return 'time';
|
||||
}
|
||||
|
||||
function isInverted(item: WidgetItem): boolean {
|
||||
return item.metadata?.invert === 'true';
|
||||
}
|
||||
|
||||
export class WeeklyUsageWidget implements Widget {
|
||||
getDefaultColor(): string { return 'brightBlue'; }
|
||||
getDescription(): string { return 'Shows weekly API usage percentage'; }
|
||||
getDisplayName(): string { return 'Weekly Usage'; }
|
||||
getCategory(): string { return 'Usage'; }
|
||||
|
||||
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
|
||||
const mode = getDisplayMode(item);
|
||||
const modifiers: string[] = [];
|
||||
|
||||
if (mode === 'progress') {
|
||||
modifiers.push('progress bar');
|
||||
} else if (mode === 'progress-short') {
|
||||
modifiers.push('short bar');
|
||||
}
|
||||
|
||||
if (isInverted(item)) {
|
||||
modifiers.push('inverted');
|
||||
}
|
||||
|
||||
return {
|
||||
displayText: this.getDisplayName(),
|
||||
modifierText: modifiers.length > 0 ? `(${modifiers.join(', ')})` : undefined
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
|
||||
if (action === 'toggle-progress') {
|
||||
const currentMode = getDisplayMode(item);
|
||||
let nextMode: DisplayMode;
|
||||
|
||||
if (currentMode === 'time') {
|
||||
nextMode = 'progress';
|
||||
} else if (currentMode === 'progress') {
|
||||
nextMode = 'progress-short';
|
||||
} else {
|
||||
nextMode = 'time';
|
||||
}
|
||||
|
||||
const nextMetadata: Record<string, string> = {
|
||||
...(item.metadata ?? {}),
|
||||
display: nextMode
|
||||
};
|
||||
|
||||
if (nextMode === 'time') {
|
||||
delete nextMetadata.invert;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
metadata: nextMetadata
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'toggle-invert') {
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
invert: (!isInverted(item)).toString()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
|
||||
const displayMode = getDisplayMode(item);
|
||||
const inverted = isInverted(item);
|
||||
|
||||
if (context.isPreview) {
|
||||
const previewPercent = 12;
|
||||
const renderedPercent = inverted ? 100 - previewPercent : previewPercent;
|
||||
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const width = displayMode === 'progress' ? 32 : 16;
|
||||
const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`;
|
||||
return item.rawValue ? progressDisplay : `Weekly: ${progressDisplay}`;
|
||||
}
|
||||
|
||||
return item.rawValue ? `${previewPercent.toFixed(1)}%` : `Weekly: ${previewPercent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
const data = fetchUsageData();
|
||||
if (data.error)
|
||||
return getUsageErrorMessage(data.error);
|
||||
if (data.weeklyUsage === undefined)
|
||||
return null;
|
||||
|
||||
const percent = Math.max(0, Math.min(100, data.weeklyUsage));
|
||||
if (displayMode === 'progress' || displayMode === 'progress-short') {
|
||||
const width = displayMode === 'progress' ? 32 : 16;
|
||||
const renderedPercent = inverted ? 100 - percent : percent;
|
||||
const progressDisplay = `${makeUsageProgressBar(renderedPercent, width)} ${renderedPercent.toFixed(1)}%`;
|
||||
return item.rawValue ? progressDisplay : `Weekly: ${progressDisplay}`;
|
||||
}
|
||||
|
||||
return item.rawValue ? `${percent.toFixed(1)}%` : `Weekly: ${percent.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
getCustomKeybinds(): CustomKeybind[] {
|
||||
return [
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
];
|
||||
}
|
||||
|
||||
supportsRawValue(): boolean { return true; }
|
||||
supportsColors(item: WidgetItem): boolean { return true; }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../../utils/usage';
|
||||
import { BlockTimerWidget } from '../BlockTimer';
|
||||
|
||||
vi.mock('../../utils/usage', () => ({
|
||||
fetchUsageData: vi.fn(),
|
||||
formatUsageDuration: vi.fn(),
|
||||
resolveUsageWindowWithFallback: vi.fn()
|
||||
}));
|
||||
|
||||
const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void };
|
||||
const mockFormatUsageDuration = formatUsageDuration as unknown as { mockReturnValue: (value: string) => void };
|
||||
const mockResolveUsageWindowWithFallback = resolveUsageWindowWithFallback as unknown as { mockReturnValue: (value: unknown) => void };
|
||||
|
||||
function render(widget: BlockTimerWidget, item: WidgetItem, context: RenderContext = {}): string | null {
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('BlockTimerWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports raw value and exposes progress/invert keybinds', () => {
|
||||
const widget = new BlockTimerWidget();
|
||||
|
||||
expect(widget.supportsRawValue()).toBe(true);
|
||||
expect(widget.getCustomKeybinds()).toEqual([
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders elapsed time in time mode', () => {
|
||||
const widget = new BlockTimerWidget();
|
||||
const item: WidgetItem = { id: 'block', type: 'block-timer' };
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 13500000,
|
||||
remainingMs: 4500000,
|
||||
elapsedPercent: 75,
|
||||
remainingPercent: 25
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('3hr 45m');
|
||||
|
||||
expect(render(widget, item)).toBe('Block: 3hr 45m');
|
||||
});
|
||||
|
||||
it('renders short progress bar with inverted fill', () => {
|
||||
const widget = new BlockTimerWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'block',
|
||||
type: 'block-timer',
|
||||
metadata: {
|
||||
display: 'progress-short',
|
||||
invert: 'true'
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 13500000,
|
||||
remainingMs: 4500000,
|
||||
elapsedPercent: 75,
|
||||
remainingPercent: 25
|
||||
});
|
||||
|
||||
expect(render(widget, item)).toBe('Block [████░░░░░░░░░░░░] 25.0%');
|
||||
});
|
||||
|
||||
it('renders empty values when no usage or fallback data exists', () => {
|
||||
const widget = new BlockTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ error: 'timeout' });
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue(null);
|
||||
|
||||
expect(render(widget, { id: 'block', type: 'block-timer' })).toBe('Block: 0hr 0m');
|
||||
expect(render(widget, {
|
||||
id: 'block',
|
||||
type: 'block-timer',
|
||||
metadata: { display: 'progress' }
|
||||
})).toBe(`Block [${'░'.repeat(32)}] 0.0%`);
|
||||
});
|
||||
|
||||
it('shows raw value without label in time mode', () => {
|
||||
const widget = new BlockTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 7200000,
|
||||
remainingMs: 10800000,
|
||||
elapsedPercent: 40,
|
||||
remainingPercent: 60
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('2hr');
|
||||
|
||||
expect(render(widget, { id: 'block', type: 'block-timer', rawValue: true })).toBe('2hr');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
const widget = new BlockTimerWidget();
|
||||
const updated = widget.handleEditorAction('toggle-progress', {
|
||||
id: 'block',
|
||||
type: 'block-timer',
|
||||
metadata: {
|
||||
display: 'progress-short',
|
||||
invert: 'true'
|
||||
}
|
||||
});
|
||||
|
||||
expect(updated?.metadata?.display).toBe('time');
|
||||
expect(updated?.metadata?.invert).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { ContextBarWidget } from '../ContextBar';
|
||||
|
||||
vi.mock('../../utils/usage', () => ({ makeUsageProgressBar: vi.fn((percent: number, width = 15) => `[bar:${percent.toFixed(1)}:${width}]`) }));
|
||||
|
||||
describe('ContextBarWidget', () => {
|
||||
it('renders from context_window data when available', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 20000,
|
||||
output_tokens: 10000,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const widget = new ContextBarWidget();
|
||||
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:15.0:16] 30k/200k (15%)');
|
||||
});
|
||||
|
||||
it('falls back to token metrics and model context size', () => {
|
||||
const context: RenderContext = {
|
||||
data: { model: { id: 'claude-3-5-sonnet-20241022' } },
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 50000
|
||||
}
|
||||
};
|
||||
const widget = new ContextBarWidget();
|
||||
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:25.0:16] 50k/200k (25%)');
|
||||
});
|
||||
|
||||
it('clamps usage percentage to 100 when context length exceeds total', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 250000,
|
||||
output_tokens: 50000,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const widget = new ContextBarWidget();
|
||||
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar' }, context, DEFAULT_SETTINGS)).toBe('Context: [bar:100.0:16] 250k/200k (100%)');
|
||||
});
|
||||
|
||||
it('supports raw mode without context label', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 5000,
|
||||
output_tokens: 5000,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const widget = new ContextBarWidget();
|
||||
|
||||
expect(widget.render({ id: 'ctx', type: 'context-bar', rawValue: true }, context, DEFAULT_SETTINGS)).toBe('[bar:2.5:16] 5k/200k (3%)');
|
||||
});
|
||||
|
||||
it('renders long progress bar mode when configured', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
current_usage: {
|
||||
input_tokens: 20000,
|
||||
output_tokens: 10000,
|
||||
cache_creation_input_tokens: 5000,
|
||||
cache_read_input_tokens: 5000
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const widget = new ContextBarWidget();
|
||||
|
||||
expect(widget.render({
|
||||
id: 'ctx',
|
||||
type: 'context-bar',
|
||||
metadata: { display: 'progress' }
|
||||
}, context, DEFAULT_SETTINGS)).toBe('Context: [bar:15.0:32] 30k/200k (15%)');
|
||||
});
|
||||
|
||||
it('toggles progress mode between short and long', () => {
|
||||
const widget = new ContextBarWidget();
|
||||
const toLong = widget.handleEditorAction('toggle-progress', { id: 'ctx', type: 'context-bar' });
|
||||
const toShort = widget.handleEditorAction('toggle-progress', {
|
||||
id: 'ctx',
|
||||
type: 'context-bar',
|
||||
metadata: { display: 'progress' }
|
||||
});
|
||||
|
||||
expect(toLong?.metadata?.display).toBe('progress');
|
||||
expect(toShort?.metadata?.display).toBe('progress-short');
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,32 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f
|
||||
}
|
||||
|
||||
describe('ContextPercentageWidget', () => {
|
||||
it('prefers context_window percentage over token metrics when both exist', () => {
|
||||
const widget = new ContextPercentageWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'context-percentage',
|
||||
type: 'context-percentage'
|
||||
};
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-3-5-sonnet-20241022' },
|
||||
context_window: {
|
||||
context_window_size: 200000,
|
||||
used_percentage: 9.3
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 100000
|
||||
}
|
||||
};
|
||||
|
||||
expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Ctx: 9.3%');
|
||||
});
|
||||
|
||||
describe('Sonnet 4.5 with 1M context window', () => {
|
||||
it('should calculate percentage using 1M denominator for Sonnet 4.5 with [1m] suffix', () => {
|
||||
const result = render('claude-sonnet-4-5-20250929[1m]', 42000);
|
||||
|
||||
@@ -34,6 +34,59 @@ function render(modelId: string | undefined, contextLength: number, rawValue = f
|
||||
}
|
||||
|
||||
describe('ContextPercentageUsableWidget', () => {
|
||||
it('prefers context_window usage over token metrics when both exist', () => {
|
||||
const widget = new ContextPercentageUsableWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'context-percentage-usable',
|
||||
type: 'context-percentage-usable'
|
||||
};
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-sonnet-4-5-20250929[1m]' },
|
||||
context_window: {
|
||||
current_usage: {
|
||||
input_tokens: 40000,
|
||||
output_tokens: 10000,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 200000
|
||||
}
|
||||
};
|
||||
|
||||
expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Ctx(u): 5.0%');
|
||||
});
|
||||
|
||||
it('uses context_window_size for usable denominator even without [1m] model suffix', () => {
|
||||
const widget = new ContextPercentageUsableWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'context-percentage-usable',
|
||||
type: 'context-percentage-usable'
|
||||
};
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
model: { id: 'claude-sonnet-4-6' },
|
||||
context_window: { context_window_size: 1000000 }
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 42000
|
||||
}
|
||||
};
|
||||
|
||||
expect(widget.render(item, context, DEFAULT_SETTINGS)).toBe('Ctx(u): 5.3%');
|
||||
});
|
||||
|
||||
describe('Sonnet 4.5 with 800k usable tokens', () => {
|
||||
it('should calculate percentage using 800k denominator for Sonnet 4.5 with [1m] suffix', () => {
|
||||
const result = render('claude-sonnet-4-5-20250929[1m]', 42000);
|
||||
@@ -44,6 +97,11 @@ describe('ContextPercentageUsableWidget', () => {
|
||||
const result = render('claude-sonnet-4-5-20250929[1m]', 42000, true);
|
||||
expect(result).toBe('5.3%');
|
||||
});
|
||||
|
||||
it('should treat [1M] suffix case-insensitively in fallback mode', () => {
|
||||
const result = render('claude-sonnet-4-5-20250929[1M]', 42000);
|
||||
expect(result).toBe('Ctx(u): 5.3%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Older models with 160k usable tokens', () => {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../../utils/usage';
|
||||
import { ResetTimerWidget } from '../ResetTimer';
|
||||
|
||||
vi.mock('../../utils/usage', () => ({
|
||||
fetchUsageData: vi.fn(),
|
||||
formatUsageDuration: vi.fn(),
|
||||
getUsageErrorMessage: vi.fn(),
|
||||
resolveUsageWindowWithFallback: vi.fn()
|
||||
}));
|
||||
|
||||
const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void };
|
||||
const mockFormatUsageDuration = formatUsageDuration as unknown as { mockReturnValue: (value: string) => void };
|
||||
const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void };
|
||||
const mockResolveUsageWindowWithFallback = resolveUsageWindowWithFallback as unknown as { mockReturnValue: (value: unknown) => void };
|
||||
|
||||
function render(widget: ResetTimerWidget, item: WidgetItem, context: RenderContext = {}): string | null {
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('ResetTimerWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('supports raw value and exposes progress/invert keybinds', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
|
||||
expect(widget.supportsRawValue()).toBe(true);
|
||||
expect(widget.getCustomKeybinds()).toEqual([
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders preview using block-style reset format', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { isPreview: true })).toBe('Reset: 4hr 30m');
|
||||
});
|
||||
|
||||
it('renders remaining time in time mode', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 3600000,
|
||||
remainingMs: 14400000,
|
||||
elapsedPercent: 20,
|
||||
remainingPercent: 80
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('4hr');
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBe('Reset: 4hr');
|
||||
});
|
||||
|
||||
it('renders short progress bar with inverted fill', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'reset',
|
||||
type: 'reset-timer',
|
||||
metadata: {
|
||||
display: 'progress-short',
|
||||
invert: 'true'
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 14400000,
|
||||
remainingMs: 3600000,
|
||||
elapsedPercent: 80,
|
||||
remainingPercent: 20
|
||||
});
|
||||
|
||||
expect(render(widget, item)).toBe('Reset [███░░░░░░░░░░░░░] 20.0%');
|
||||
});
|
||||
|
||||
it('returns usage error when no timer data is available', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ error: 'timeout' });
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue(null);
|
||||
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('returns null when neither timer data nor usage error exists', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue(null);
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows raw value without label in time mode', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 4500000,
|
||||
remainingMs: 13500000,
|
||||
elapsedPercent: 25,
|
||||
remainingPercent: 75
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('3hr 45m');
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true })).toBe('3hr 45m');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
const widget = new ResetTimerWidget();
|
||||
const updated = widget.handleEditorAction('toggle-progress', {
|
||||
id: 'reset',
|
||||
type: 'reset-timer',
|
||||
metadata: {
|
||||
display: 'progress-short',
|
||||
invert: 'true'
|
||||
}
|
||||
});
|
||||
|
||||
expect(updated?.metadata?.display).toBe('time');
|
||||
expect(updated?.metadata?.invert).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type {
|
||||
RenderContext,
|
||||
WidgetItem
|
||||
} from '../../types';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { SessionClockWidget } from '../SessionClock';
|
||||
|
||||
function render(item: WidgetItem, context: RenderContext = {}): string | null {
|
||||
const widget = new SessionClockWidget();
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('SessionClockWidget', () => {
|
||||
it('uses cost.total_duration_ms when available', () => {
|
||||
expect(render(
|
||||
{ id: 'session-clock', type: 'session-clock' },
|
||||
{ data: { cost: { total_duration_ms: 2 * 60 * 60 * 1000 + 15 * 60 * 1000 } } }
|
||||
)).toBe('Session: 2hr 15m');
|
||||
});
|
||||
|
||||
it('supports raw value with cost.total_duration_ms', () => {
|
||||
expect(render(
|
||||
{ id: 'session-clock', type: 'session-clock', rawValue: true },
|
||||
{ data: { cost: { total_duration_ms: 30 * 1000 } } }
|
||||
)).toBe('<1m');
|
||||
});
|
||||
|
||||
it('falls back to sessionDuration when status JSON duration is missing', () => {
|
||||
expect(render(
|
||||
{ id: 'session-clock', type: 'session-clock' },
|
||||
{ sessionDuration: '3hr 20m' }
|
||||
)).toBe('Session: 3hr 20m');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
getUsageErrorMessage
|
||||
} from '../../utils/usage';
|
||||
import { SessionUsageWidget } from '../SessionUsage';
|
||||
|
||||
vi.mock('../../utils/usage', () => ({
|
||||
fetchUsageData: vi.fn(),
|
||||
getUsageErrorMessage: vi.fn(),
|
||||
makeUsageProgressBar: vi.fn((percent: number, width = 15) => `[bar:${percent.toFixed(1)}:${width}]`)
|
||||
}));
|
||||
|
||||
const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void };
|
||||
const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void };
|
||||
|
||||
function render(widget: SessionUsageWidget, item: WidgetItem, context: RenderContext = {}): string | null {
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('SessionUsageWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('exposes progress and invert keybinds', () => {
|
||||
const widget = new SessionUsageWidget();
|
||||
|
||||
expect(widget.supportsRawValue()).toBe(true);
|
||||
expect(widget.getCustomKeybinds()).toEqual([
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders percentage text in time mode', () => {
|
||||
const widget = new SessionUsageWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 });
|
||||
|
||||
expect(render(widget, { id: 'session', type: 'session-usage' })).toBe('Session: 23.4%');
|
||||
});
|
||||
|
||||
it('renders short inverted progress mode', () => {
|
||||
const widget = new SessionUsageWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'session',
|
||||
type: 'session-usage',
|
||||
metadata: {
|
||||
display: 'progress-short',
|
||||
invert: 'true'
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 });
|
||||
|
||||
expect(render(widget, item)).toBe('Session: [bar:76.5:16] 76.5%');
|
||||
});
|
||||
|
||||
it('renders raw text mode without label', () => {
|
||||
const widget = new SessionUsageWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 });
|
||||
|
||||
expect(render(widget, { id: 'session', type: 'session-usage', rawValue: true })).toBe('23.4%');
|
||||
});
|
||||
|
||||
it('renders raw progress mode without label', () => {
|
||||
const widget = new SessionUsageWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'session',
|
||||
type: 'session-usage',
|
||||
rawValue: true,
|
||||
metadata: { display: 'progress' }
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 });
|
||||
|
||||
expect(render(widget, item)).toBe('[bar:23.4:32] 23.4%');
|
||||
});
|
||||
|
||||
it('shows usage error text when API call fails', () => {
|
||||
const widget = new SessionUsageWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ error: 'timeout' });
|
||||
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');
|
||||
|
||||
expect(render(widget, { id: 'session', type: 'session-usage' })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
const widget = new SessionUsageWidget();
|
||||
const updated = widget.handleEditorAction('toggle-progress', {
|
||||
id: 'session',
|
||||
type: 'session-usage',
|
||||
metadata: {
|
||||
display: 'progress-short',
|
||||
invert: 'true'
|
||||
}
|
||||
});
|
||||
|
||||
expect(updated?.metadata?.display).toBe('time');
|
||||
expect(updated?.metadata?.invert).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import { TokensCachedWidget } from '../TokensCached';
|
||||
import { TokensInputWidget } from '../TokensInput';
|
||||
import { TokensOutputWidget } from '../TokensOutput';
|
||||
import { TokensTotalWidget } from '../TokensTotal';
|
||||
|
||||
describe('Token widgets', () => {
|
||||
it('use context_window values for input/output and tokenMetrics totals for cached/total', () => {
|
||||
const context: RenderContext = {
|
||||
data: {
|
||||
context_window: {
|
||||
total_input_tokens: 1111,
|
||||
total_output_tokens: 2222,
|
||||
current_usage: {
|
||||
input_tokens: 300,
|
||||
output_tokens: 400,
|
||||
cache_creation_input_tokens: 50,
|
||||
cache_read_input_tokens: 25
|
||||
}
|
||||
}
|
||||
},
|
||||
tokenMetrics: {
|
||||
inputTokens: 9999,
|
||||
outputTokens: 9999,
|
||||
cachedTokens: 9999,
|
||||
totalTokens: 9999,
|
||||
contextLength: 9999
|
||||
}
|
||||
};
|
||||
|
||||
expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: 1.1k');
|
||||
expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: 2.2k');
|
||||
expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: 10.0k');
|
||||
expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: 10.0k');
|
||||
});
|
||||
|
||||
it('fall back to token metrics when context_window data is missing', () => {
|
||||
const context: RenderContext = {
|
||||
tokenMetrics: {
|
||||
inputTokens: 1200,
|
||||
outputTokens: 3400,
|
||||
cachedTokens: 560,
|
||||
totalTokens: 5160,
|
||||
contextLength: 0
|
||||
}
|
||||
};
|
||||
|
||||
expect(new TokensInputWidget().render({ id: 'in', type: 'tokens-input' }, context, DEFAULT_SETTINGS)).toBe('In: 1.2k');
|
||||
expect(new TokensOutputWidget().render({ id: 'out', type: 'tokens-output' }, context, DEFAULT_SETTINGS)).toBe('Out: 3.4k');
|
||||
expect(new TokensCachedWidget().render({ id: 'cached', type: 'tokens-cached' }, context, DEFAULT_SETTINGS)).toBe('Cached: 560');
|
||||
expect(new TokensTotalWidget().render({ id: 'total', type: 'tokens-total' }, context, DEFAULT_SETTINGS)).toBe('Total: 5.2k');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
getUsageErrorMessage
|
||||
} from '../../utils/usage';
|
||||
import { WeeklyUsageWidget } from '../WeeklyUsage';
|
||||
|
||||
vi.mock('../../utils/usage', () => ({
|
||||
fetchUsageData: vi.fn(),
|
||||
getUsageErrorMessage: vi.fn(),
|
||||
makeUsageProgressBar: vi.fn((percent: number, width = 15) => `[bar:${percent.toFixed(1)}:${width}]`)
|
||||
}));
|
||||
|
||||
const mockFetchUsageData = fetchUsageData as unknown as { mockReturnValue: (value: unknown) => void };
|
||||
const mockGetUsageErrorMessage = getUsageErrorMessage as unknown as { mockReturnValue: (value: string) => void };
|
||||
|
||||
function render(widget: WeeklyUsageWidget, item: WidgetItem, context: RenderContext = {}): string | null {
|
||||
return widget.render(item, context, DEFAULT_SETTINGS);
|
||||
}
|
||||
|
||||
describe('WeeklyUsageWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('exposes progress and invert keybinds', () => {
|
||||
const widget = new WeeklyUsageWidget();
|
||||
|
||||
expect(widget.supportsRawValue()).toBe(true);
|
||||
expect(widget.getCustomKeybinds()).toEqual([
|
||||
{ key: 'p', label: '(p)rogress toggle', action: 'toggle-progress' },
|
||||
{ key: 'v', label: 'in(v)ert fill', action: 'toggle-invert' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders percentage text in time mode', () => {
|
||||
const widget = new WeeklyUsageWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 });
|
||||
|
||||
expect(render(widget, { id: 'weekly', type: 'weekly-usage' })).toBe('Weekly: 42.1%');
|
||||
});
|
||||
|
||||
it('renders full inverted progress mode', () => {
|
||||
const widget = new WeeklyUsageWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'weekly',
|
||||
type: 'weekly-usage',
|
||||
metadata: {
|
||||
display: 'progress',
|
||||
invert: 'true'
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 });
|
||||
|
||||
expect(render(widget, item)).toBe('Weekly: [bar:57.9:32] 57.9%');
|
||||
});
|
||||
|
||||
it('renders raw text mode without label', () => {
|
||||
const widget = new WeeklyUsageWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 });
|
||||
|
||||
expect(render(widget, { id: 'weekly', type: 'weekly-usage', rawValue: true })).toBe('42.1%');
|
||||
});
|
||||
|
||||
it('renders raw progress mode without label', () => {
|
||||
const widget = new WeeklyUsageWidget();
|
||||
const item: WidgetItem = {
|
||||
id: 'weekly',
|
||||
type: 'weekly-usage',
|
||||
rawValue: true,
|
||||
metadata: { display: 'progress-short' }
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 });
|
||||
|
||||
expect(render(widget, item)).toBe('[bar:42.1:16] 42.1%');
|
||||
});
|
||||
|
||||
it('shows usage error text when API call fails', () => {
|
||||
const widget = new WeeklyUsageWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ error: 'timeout' });
|
||||
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');
|
||||
|
||||
expect(render(widget, { id: 'weekly', type: 'weekly-usage' })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
const widget = new WeeklyUsageWidget();
|
||||
const updated = widget.handleEditorAction('toggle-progress', {
|
||||
id: 'weekly',
|
||||
type: 'weekly-usage',
|
||||
metadata: {
|
||||
display: 'progress-short',
|
||||
invert: 'true'
|
||||
}
|
||||
});
|
||||
|
||||
expect(updated?.metadata?.display).toBe('time');
|
||||
expect(updated?.metadata?.invert).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -21,4 +21,8 @@ export { BlockTimerWidget } from './BlockTimer';
|
||||
export { CurrentWorkingDirWidget } from './CurrentWorkingDir';
|
||||
export { ClaudeSessionIdWidget } from './ClaudeSessionId';
|
||||
export { FreeMemoryWidget } from './FreeMemory';
|
||||
export { SessionNameWidget } from './SessionName';
|
||||
export { SessionNameWidget } from './SessionName';
|
||||
export { SessionUsageWidget } from './SessionUsage';
|
||||
export { WeeklyUsageWidget } from './WeeklyUsage';
|
||||
export { ResetTimerWidget } from './ResetTimer';
|
||||
export { ContextBarWidget } from './ContextBar';
|
||||
Reference in new issue
Block a user