mirror of
https://github.com/tiennm99/ccstatusline.git
synced 2026-10-14 06:17:28 +00:00
refactor: implement phase 6 jsonl/usage modularization with async prefetch tests
This commit is contained in:
1 parent
a34a632b82
commit
fc09688879
26 files changed
+1550
-1060
No files matched your search
@@ -22,6 +22,7 @@ import {
|
||||
renderStatusLine
|
||||
} from './utils/renderer';
|
||||
import { advanceGlobalSeparatorIndex } from './utils/separator-index';
|
||||
import { prefetchUsageDataIfNeeded } from './utils/usage-prefetch';
|
||||
|
||||
function hasSessionDurationInStatusJson(data: StatusJSON): boolean {
|
||||
const durationMs = data.cost?.total_duration_ms;
|
||||
@@ -95,10 +96,13 @@ async function renderMultipleLines(data: StatusJSON) {
|
||||
sessionDuration = await getSessionDuration(data.transcript_path);
|
||||
}
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
// Create render context
|
||||
const context: RenderContext = {
|
||||
data,
|
||||
tokenMetrics,
|
||||
usageData,
|
||||
sessionDuration,
|
||||
isPreview: false
|
||||
};
|
||||
|
||||
@@ -3,9 +3,22 @@ import type { BlockMetrics } from '../types';
|
||||
import type { StatusJSON } from './StatusJSON';
|
||||
import type { TokenMetrics } from './TokenMetrics';
|
||||
|
||||
export interface RenderUsageData {
|
||||
sessionUsage?: number;
|
||||
sessionResetAt?: string;
|
||||
weeklyUsage?: number;
|
||||
weeklyResetAt?: string;
|
||||
extraUsageEnabled?: boolean;
|
||||
extraUsageLimit?: number;
|
||||
extraUsageUsed?: number;
|
||||
extraUsageUtilization?: number;
|
||||
error?: 'no-credentials' | 'timeout' | 'api-error' | 'parse-error';
|
||||
}
|
||||
|
||||
export interface RenderContext {
|
||||
data?: StatusJSON;
|
||||
tokenMetrics?: TokenMetrics | null;
|
||||
usageData?: RenderUsageData | null;
|
||||
sessionDuration?: string | null;
|
||||
blockMetrics?: BlockMetrics | null;
|
||||
terminalWidth?: number | null;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { getBlockMetrics } from '../jsonl';
|
||||
|
||||
function floorToHourUtc(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
|
||||
function makeUsageLine(timestamp: Date): string {
|
||||
return JSON.stringify({
|
||||
timestamp: timestamp.toISOString(),
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('jsonl block metrics integration', () => {
|
||||
let tempClaudeDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempClaudeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-blocks-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempClaudeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
}
|
||||
fs.rmSync(tempClaudeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns the current block start for recent activity after an older session gap', () => {
|
||||
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
const transcriptPath = path.join(projectsDir, 'session.jsonl');
|
||||
|
||||
const now = new Date();
|
||||
const oldActivity = new Date(now.getTime() - (10 * 60 * 60 * 1000));
|
||||
const currentBlockStartSource = new Date(now.getTime() - (2 * 60 * 60 * 1000) - (10 * 60 * 1000));
|
||||
const recentActivity = new Date(now.getTime() - (40 * 60 * 1000));
|
||||
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
makeUsageLine(oldActivity),
|
||||
makeUsageLine(currentBlockStartSource),
|
||||
makeUsageLine(recentActivity)
|
||||
].join('\n'));
|
||||
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
expect(metrics).not.toBeNull();
|
||||
expect(metrics?.startTime.toISOString()).toBe(floorToHourUtc(currentBlockStartSource).toISOString());
|
||||
expect(metrics?.lastActivity.toISOString()).toBe(recentActivity.toISOString());
|
||||
});
|
||||
|
||||
it('returns null when the most recent activity is older than the session window', () => {
|
||||
const projectsDir = path.join(tempClaudeDir, 'projects', 'project-a');
|
||||
fs.mkdirSync(projectsDir, { recursive: true });
|
||||
const transcriptPath = path.join(projectsDir, 'stale-session.jsonl');
|
||||
|
||||
const now = new Date();
|
||||
const staleActivity = new Date(now.getTime() - (6 * 60 * 60 * 1000));
|
||||
|
||||
fs.writeFileSync(transcriptPath, makeUsageLine(staleActivity));
|
||||
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
expect(metrics).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import {
|
||||
afterEach,
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import {
|
||||
getSessionDuration,
|
||||
getTokenMetrics
|
||||
} from '../jsonl';
|
||||
|
||||
function makeUsageLine(params: {
|
||||
timestamp: string;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead?: number;
|
||||
cacheCreate?: number;
|
||||
isSidechain?: boolean;
|
||||
isApiErrorMessage?: boolean;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
timestamp: params.timestamp,
|
||||
isSidechain: params.isSidechain,
|
||||
isApiErrorMessage: params.isApiErrorMessage,
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: params.input,
|
||||
output_tokens: params.output,
|
||||
cache_read_input_tokens: params.cacheRead,
|
||||
cache_creation_input_tokens: params.cacheCreate
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe('jsonl transcript metrics', () => {
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (tempRoots.length > 0) {
|
||||
const root = tempRoots.pop();
|
||||
if (root) {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('formats session duration as <1m for sub-minute transcripts', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'short.jsonl');
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:30.000Z' })
|
||||
].join('\n'));
|
||||
|
||||
const duration = await getSessionDuration(transcriptPath);
|
||||
|
||||
expect(duration).toBe('<1m');
|
||||
});
|
||||
|
||||
it('formats multi-hour session durations', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'long.jsonl');
|
||||
fs.writeFileSync(transcriptPath, [
|
||||
JSON.stringify({ timestamp: '2026-01-01T10:00:00.000Z' }),
|
||||
JSON.stringify({ timestamp: '2026-01-01T12:05:00.000Z' })
|
||||
].join('\n'));
|
||||
|
||||
const duration = await getSessionDuration(transcriptPath);
|
||||
|
||||
expect(duration).toBe('2hr 5m');
|
||||
});
|
||||
|
||||
it('returns null for missing transcript files', async () => {
|
||||
const duration = await getSessionDuration('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
|
||||
expect(duration).toBeNull();
|
||||
});
|
||||
|
||||
it('aggregates token totals and computes context length from the latest main-chain non-error entry', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ccstatusline-jsonl-metrics-'));
|
||||
tempRoots.push(root);
|
||||
const transcriptPath = path.join(root, 'tokens.jsonl');
|
||||
|
||||
const lines = [
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T10:00:00.000Z',
|
||||
input: 100,
|
||||
output: 50,
|
||||
cacheRead: 20,
|
||||
cacheCreate: 10
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:00:00.000Z',
|
||||
input: 200,
|
||||
output: 80,
|
||||
cacheRead: 30,
|
||||
cacheCreate: 20
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:30:00.000Z',
|
||||
input: 500,
|
||||
output: 10,
|
||||
cacheRead: 5,
|
||||
cacheCreate: 5,
|
||||
isSidechain: true
|
||||
}),
|
||||
makeUsageLine({
|
||||
timestamp: '2026-01-01T11:45:00.000Z',
|
||||
input: 999,
|
||||
output: 1,
|
||||
cacheRead: 1,
|
||||
cacheCreate: 1,
|
||||
isApiErrorMessage: true
|
||||
})
|
||||
];
|
||||
|
||||
fs.writeFileSync(transcriptPath, lines.join('\n'));
|
||||
|
||||
const metrics = await getTokenMetrics(transcriptPath);
|
||||
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 1799,
|
||||
outputTokens: 141,
|
||||
cachedTokens: 92,
|
||||
totalTokens: 2032,
|
||||
contextLength: 250
|
||||
});
|
||||
});
|
||||
|
||||
it('returns zeroed token metrics when file is missing', async () => {
|
||||
const metrics = await getTokenMetrics('/tmp/ccstatusline-jsonl-metrics-missing.jsonl');
|
||||
expect(metrics).toEqual({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedTokens: 0,
|
||||
totalTokens: 0,
|
||||
contextLength: 0
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} from 'vitest';
|
||||
|
||||
interface UsageProbeResult {
|
||||
first: { error?: string };
|
||||
second: { error?: string };
|
||||
first: Record<string, unknown>;
|
||||
second: Record<string, unknown>;
|
||||
lockExists: boolean;
|
||||
cacheExists: boolean;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
describe('fetchUsageData error handling', () => {
|
||||
@@ -25,42 +27,125 @@ describe('fetchUsageData error handling', () => {
|
||||
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 successHome = path.join(tempRoot, 'home-success');
|
||||
const successBin = path.join(tempRoot, 'bin-success');
|
||||
const successClaudeConfig = path.join(tempRoot, 'claude-success');
|
||||
const securityScript = path.join(apiErrorBin, 'security');
|
||||
const successSecurityScript = path.join(successBin, 'security');
|
||||
const credentialsFile = path.join(apiErrorClaudeConfig, '.credentials.json');
|
||||
const successCredentialsFile = path.join(successClaudeConfig, '.credentials.json');
|
||||
const successResponseBody = JSON.stringify({
|
||||
five_hour: {
|
||||
utilization: 42,
|
||||
resets_at: '2030-01-01T00:00:00.000Z'
|
||||
},
|
||||
seven_day: {
|
||||
utilization: 17,
|
||||
resets_at: '2030-01-07T00:00:00.000Z'
|
||||
}
|
||||
});
|
||||
|
||||
fs.mkdirSync(noCredentialsHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorHome, { recursive: true });
|
||||
fs.mkdirSync(apiErrorBin, { recursive: true });
|
||||
fs.mkdirSync(apiErrorClaudeConfig, { recursive: true });
|
||||
fs.mkdirSync(successHome, { recursive: true });
|
||||
fs.mkdirSync(successBin, { recursive: true });
|
||||
fs.mkdirSync(successClaudeConfig, { 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' } }));
|
||||
fs.writeFileSync(successSecurityScript, '#!/bin/sh\necho \'{"claudeAiOauth":{"accessToken":"test-token"}}\'\n');
|
||||
fs.chmodSync(successSecurityScript, 0o755);
|
||||
fs.writeFileSync(successCredentialsFile, 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)};
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const https = require('https');
|
||||
const mode = process.env.TEST_REQUEST_MODE || 'success';
|
||||
const responseBody = process.env.TEST_RESPONSE_BODY || '';
|
||||
let requestCount = 0;
|
||||
|
||||
https.request = (...args) => {
|
||||
requestCount += 1;
|
||||
const callback = args.find(value => typeof value === 'function');
|
||||
const requestHandlers = new Map();
|
||||
const responseHandlers = new Map();
|
||||
|
||||
const response = {
|
||||
statusCode: mode === 'success' ? 200 : 500,
|
||||
setEncoding() {},
|
||||
on(event, handler) {
|
||||
const existing = responseHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
responseHandlers.set(event, existing);
|
||||
return response;
|
||||
}
|
||||
};
|
||||
|
||||
const request = {
|
||||
on(event, handler) {
|
||||
const existing = requestHandlers.get(event) || [];
|
||||
existing.push(handler);
|
||||
requestHandlers.set(event, existing);
|
||||
return request;
|
||||
},
|
||||
destroy() {},
|
||||
end() {
|
||||
if (mode === 'error') {
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('mock request failure'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'success') {
|
||||
if (callback) {
|
||||
callback(response);
|
||||
}
|
||||
const dataHandlers = responseHandlers.get('data') || [];
|
||||
for (const handler of dataHandlers) {
|
||||
handler(Buffer.from(responseBody));
|
||||
}
|
||||
const endHandlers = responseHandlers.get('end') || [];
|
||||
for (const handler of endHandlers) {
|
||||
handler();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const handlers = requestHandlers.get('error') || [];
|
||||
for (const handler of handlers) {
|
||||
handler(new Error('unexpected request'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return request;
|
||||
};
|
||||
|
||||
const { fetchUsageData } = await import(${JSON.stringify(usageModulePath)});
|
||||
|
||||
const lockFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.lock');
|
||||
const nowMs = Date.now() + (10 * 365 * 24 * 60 * 60 * 1000);
|
||||
const cacheFile = path.join(os.homedir(), '.cache', 'ccstatusline', 'usage.json');
|
||||
const nowMs = Number(process.env.TEST_NOW_MS || Date.now());
|
||||
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();
|
||||
const first = await fetchUsageData();
|
||||
const second = await fetchUsageData();
|
||||
process.stdout.write(JSON.stringify({
|
||||
first,
|
||||
second,
|
||||
lockExists: fs.existsSync(lockFile)
|
||||
lockExists: fs.existsSync(lockFile),
|
||||
cacheExists: fs.existsSync(cacheFile),
|
||||
requestCount
|
||||
}));
|
||||
`;
|
||||
|
||||
@@ -73,7 +158,8 @@ process.stdout.write(JSON.stringify({
|
||||
...process.env,
|
||||
HOME: noCredentialsHome,
|
||||
PATH: '/nonexistent',
|
||||
TEST_FORCE_BAD_EXEC_PATH: '0'
|
||||
TEST_REQUEST_MODE: 'unexpected',
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,6 +167,8 @@ process.stdout.write(JSON.stringify({
|
||||
expect(noCredentialsResult.first).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.second).toEqual({ error: 'no-credentials' });
|
||||
expect(noCredentialsResult.lockExists).toBe(false);
|
||||
expect(noCredentialsResult.cacheExists).toBe(false);
|
||||
expect(noCredentialsResult.requestCount).toBe(0);
|
||||
|
||||
const apiErrorOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
@@ -89,13 +177,58 @@ process.stdout.write(JSON.stringify({
|
||||
HOME: apiErrorHome,
|
||||
PATH: apiErrorBin,
|
||||
CLAUDE_CONFIG_DIR: apiErrorClaudeConfig,
|
||||
TEST_FORCE_BAD_EXEC_PATH: '1'
|
||||
TEST_REQUEST_MODE: 'error',
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
});
|
||||
|
||||
const apiErrorResult = JSON.parse(apiErrorOutput) as UsageProbeResult;
|
||||
expect(apiErrorResult.first).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.second).toEqual({ error: 'api-error' });
|
||||
expect(apiErrorResult.cacheExists).toBe(false);
|
||||
expect(apiErrorResult.requestCount).toBe(1);
|
||||
|
||||
const successOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: successHome,
|
||||
PATH: successBin,
|
||||
CLAUDE_CONFIG_DIR: successClaudeConfig,
|
||||
TEST_REQUEST_MODE: 'success',
|
||||
TEST_RESPONSE_BODY: successResponseBody,
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
});
|
||||
|
||||
const successResult = JSON.parse(successOutput) as UsageProbeResult;
|
||||
expect(successResult.first).toEqual({
|
||||
sessionUsage: 42,
|
||||
sessionResetAt: '2030-01-01T00:00:00.000Z',
|
||||
weeklyUsage: 17,
|
||||
weeklyResetAt: '2030-01-07T00:00:00.000Z'
|
||||
});
|
||||
expect(successResult.second).toEqual(successResult.first);
|
||||
expect(successResult.cacheExists).toBe(true);
|
||||
expect(successResult.requestCount).toBe(1);
|
||||
|
||||
const cachedSuccessOutput = execFileSync(process.execPath, [probeScriptPath], {
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: successHome,
|
||||
PATH: successBin,
|
||||
CLAUDE_CONFIG_DIR: successClaudeConfig,
|
||||
TEST_REQUEST_MODE: 'unexpected',
|
||||
TEST_NOW_MS: '2200000000000'
|
||||
}
|
||||
});
|
||||
|
||||
const cachedSuccessResult = JSON.parse(cachedSuccessOutput) as UsageProbeResult;
|
||||
expect(cachedSuccessResult.first).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.second).toEqual(successResult.first);
|
||||
expect(cachedSuccessResult.cacheExists).toBe(true);
|
||||
expect(cachedSuccessResult.requestCount).toBe(1);
|
||||
} finally {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import { fetchUsageData } from '../usage';
|
||||
import {
|
||||
hasUsageDependentWidgets,
|
||||
prefetchUsageDataIfNeeded
|
||||
} from '../usage-prefetch';
|
||||
|
||||
vi.mock('../usage', () => ({ fetchUsageData: vi.fn() }));
|
||||
|
||||
const mockFetchUsageData = fetchUsageData as unknown as {
|
||||
mockClear: () => void;
|
||||
mockResolvedValue: (value: unknown) => void;
|
||||
mock: { calls: unknown[][] };
|
||||
};
|
||||
|
||||
function makeLines(...lineItems: WidgetItem[][]): WidgetItem[][] {
|
||||
return lineItems;
|
||||
}
|
||||
|
||||
describe('usage prefetch', () => {
|
||||
it('detects when usage widgets are present', () => {
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'block-timer' }]
|
||||
);
|
||||
|
||||
expect(hasUsageDependentWidgets(lines)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not detect usage requirement for non-usage widgets', () => {
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'git-branch' }]
|
||||
);
|
||||
|
||||
expect(hasUsageDependentWidgets(lines)).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches usage data once when at least one usage widget exists', async () => {
|
||||
mockFetchUsageData.mockClear();
|
||||
mockFetchUsageData.mockResolvedValue({ sessionUsage: 12.3 });
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'session-usage' }, { id: '3', type: 'weekly-usage' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
expect(usageData).toEqual({ sessionUsage: 12.3 });
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not fetch usage data when no usage widgets exist', async () => {
|
||||
mockFetchUsageData.mockClear();
|
||||
|
||||
const lines = makeLines(
|
||||
[{ id: '1', type: 'model' }],
|
||||
[{ id: '2', type: 'git-branch' }]
|
||||
);
|
||||
|
||||
const usageData = await prefetchUsageDataIfNeeded(lines);
|
||||
|
||||
expect(usageData).toBeNull();
|
||||
expect(mockFetchUsageData.mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import * as fs from 'fs';
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import {
|
||||
parseJsonlLine,
|
||||
readJsonlLinesSync
|
||||
} from './jsonl-lines';
|
||||
|
||||
const statSync = fs.statSync;
|
||||
|
||||
/**
|
||||
* Gets block metrics for the current 5-hour block from JSONL files
|
||||
*/
|
||||
export function getBlockMetrics(): BlockMetrics | null {
|
||||
const claudeDir: string | null = getClaudeConfigDir();
|
||||
|
||||
if (!claudeDir)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return findMostRecentBlockStartTime(claudeDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently finds the most recent 5-hour block start time from JSONL files
|
||||
* Uses file modification times as hints to avoid unnecessary reads
|
||||
*/
|
||||
function findMostRecentBlockStartTime(
|
||||
rootDir: string,
|
||||
sessionDurationHours = 5
|
||||
): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Step 1: Find all JSONL files with their modification times
|
||||
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
|
||||
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
|
||||
const files = globSync([pattern], {
|
||||
absolute: true, // Ensure we get absolute paths
|
||||
cwd: rootDir // Set working directory to rootDir
|
||||
});
|
||||
|
||||
if (files.length === 0)
|
||||
return null;
|
||||
|
||||
// Step 2: Get file stats and sort by modification time (most recent first)
|
||||
const filesWithStats = files.map((file) => {
|
||||
const stats = statSync(file);
|
||||
return { file, mtime: stats.mtime };
|
||||
});
|
||||
|
||||
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Step 3: Progressive lookback - start small and expand if needed
|
||||
// Start with 2x session duration (10 hours), expand to 48 hours if needed
|
||||
const lookbackChunks = [
|
||||
10, // 2x session duration - catches most cases
|
||||
20, // 4x session duration - catches longer sessions
|
||||
48 // Maximum lookback for marathon sessions
|
||||
];
|
||||
|
||||
let timestamps: Date[] = [];
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
let continuousWorkStart: Date | null = null;
|
||||
let foundSessionGap = false;
|
||||
|
||||
for (const lookbackHours of lookbackChunks) {
|
||||
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
timestamps = [];
|
||||
|
||||
// Collect timestamps for this lookback period
|
||||
for (const { file, mtime } of filesWithStats) {
|
||||
if (mtime.getTime() < cutoffTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const fileTimestamps = getAllTimestampsFromFile(file);
|
||||
timestamps.push(...fileTimestamps);
|
||||
}
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
continue; // Try next chunk
|
||||
}
|
||||
|
||||
// Sort timestamps (most recent first)
|
||||
timestamps.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
// Get most recent timestamp (only set once)
|
||||
if (!mostRecentTimestamp && timestamps[0]) {
|
||||
mostRecentTimestamp = timestamps[0];
|
||||
|
||||
// Check if the most recent activity is within the current session period
|
||||
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
|
||||
if (timeSinceLastActivity > sessionDurationMs) {
|
||||
// No activity within the current session period
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for a session gap in this chunk
|
||||
continuousWorkStart = mostRecentTimestamp;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const currentTimestamp = timestamps[i];
|
||||
const previousTimestamp = timestamps[i - 1];
|
||||
|
||||
if (!currentTimestamp || !previousTimestamp)
|
||||
continue;
|
||||
|
||||
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
|
||||
|
||||
if (gap >= sessionDurationMs) {
|
||||
// Found a true session boundary
|
||||
foundSessionGap = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continuousWorkStart = currentTimestamp;
|
||||
}
|
||||
|
||||
// If we found a gap, we're done
|
||||
if (foundSessionGap) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If this was our last chunk, use what we have
|
||||
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentTimestamp || !continuousWorkStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build actual blocks from timestamps going forward
|
||||
const blocks: { start: Date; end: Date }[] = [];
|
||||
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
let currentBlockStart: Date | null = null;
|
||||
let currentBlockEnd: Date | null = null;
|
||||
|
||||
for (const timestamp of sortedTimestamps) {
|
||||
if (timestamp.getTime() < continuousWorkStart.getTime())
|
||||
continue;
|
||||
|
||||
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
|
||||
// Start new block
|
||||
currentBlockStart = floorToHour(timestamp);
|
||||
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
|
||||
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
|
||||
}
|
||||
}
|
||||
|
||||
// Find current block
|
||||
for (const block of blocks) {
|
||||
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
|
||||
// Verify we have activity in this block
|
||||
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
|
||||
&& t.getTime() <= block.end.getTime()
|
||||
);
|
||||
|
||||
if (hasActivity) {
|
||||
return {
|
||||
startTime: block.start,
|
||||
lastActivity: mostRecentTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all timestamps from a JSONL file
|
||||
*/
|
||||
function getAllTimestampsFromFile(filePath: string): Date[] {
|
||||
const timestamps: Date[] = [];
|
||||
try {
|
||||
const lines = readJsonlLinesSync(filePath);
|
||||
|
||||
for (const line of lines) {
|
||||
const json = parseJsonlLine(line) as {
|
||||
timestamp?: string;
|
||||
isSidechain?: boolean;
|
||||
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
|
||||
} | null;
|
||||
if (!json) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only treat entries with real token usage as block activity
|
||||
const usage = json.message?.usage;
|
||||
if (!usage)
|
||||
continue;
|
||||
|
||||
const hasInputTokens = typeof usage.input_tokens === 'number';
|
||||
const hasOutputTokens = typeof usage.output_tokens === 'number';
|
||||
if (!hasInputTokens || !hasOutputTokens)
|
||||
continue;
|
||||
|
||||
if (json.isSidechain === true)
|
||||
continue;
|
||||
|
||||
const timestamp = json.timestamp;
|
||||
if (typeof timestamp !== 'string')
|
||||
continue;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
timestamps.push(date);
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floors a timestamp to the beginning of the hour (matching existing logic)
|
||||
*/
|
||||
function floorToHour(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as fs from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import { getBlockMetrics } from './jsonl-blocks';
|
||||
|
||||
const readFileSync = fs.readFileSync;
|
||||
const writeFileSync = fs.writeFileSync;
|
||||
const mkdirSync = fs.mkdirSync;
|
||||
const existsSync = fs.existsSync;
|
||||
|
||||
interface BlockCache {
|
||||
startTime: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigDir(configDir: string): string {
|
||||
return path.resolve(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the block cache file for a specific Claude config directory
|
||||
*/
|
||||
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const configHash = createHash('sha256')
|
||||
.update(normalizedConfigDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
'ccstatusline',
|
||||
`block-cache-${configHash}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the block cache file and returns the cached start time
|
||||
* Returns null if cache doesn't exist or is invalid
|
||||
*/
|
||||
export function readBlockCache(expectedConfigDir?: string): Date | null {
|
||||
try {
|
||||
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
|
||||
? normalizeConfigDir(expectedConfigDir)
|
||||
: undefined;
|
||||
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(cachePath, 'utf-8');
|
||||
const cache = JSON.parse(content) as BlockCache;
|
||||
if (typeof cache.startTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (normalizedExpectedConfigDir !== undefined) {
|
||||
if (typeof cache.configDir !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (cache.configDir !== normalizedExpectedConfigDir) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const date = new Date(cache.startTime);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the block start time to the cache file
|
||||
* Creates the cache directory if it doesn't exist
|
||||
*/
|
||||
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
|
||||
try {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const cachePath = getBlockCachePath(normalizedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
const cache: BlockCache = {
|
||||
startTime: startTime.toISOString(),
|
||||
configDir: normalizedConfigDir
|
||||
};
|
||||
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - caching is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics with caching support
|
||||
* Returns cached result if still valid, otherwise recalculates
|
||||
*/
|
||||
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const activeConfigDir = getClaudeConfigDir();
|
||||
|
||||
// Check cache first
|
||||
const cachedStartTime = readBlockCache(activeConfigDir);
|
||||
if (cachedStartTime) {
|
||||
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
|
||||
if (now.getTime() <= blockEndTime.getTime()) {
|
||||
// Cache is valid - return cached result
|
||||
return {
|
||||
startTime: cachedStartTime,
|
||||
lastActivity: now // We don't cache lastActivity, use current time
|
||||
};
|
||||
}
|
||||
// Cache expired - need to recalculate
|
||||
}
|
||||
|
||||
// Cache miss or expired - run full calculation
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
// Write to cache if we found a valid block
|
||||
if (metrics) {
|
||||
writeBlockCache(metrics.startTime, activeConfigDir);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as fs from 'fs';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const readFile = promisify(fs.readFile);
|
||||
const readFileSync = fs.readFileSync;
|
||||
|
||||
function splitJsonlContent(content: string): string[] {
|
||||
return content.trim().split('\n').filter(line => line.length > 0);
|
||||
}
|
||||
|
||||
export async function readJsonlLines(filePath: string): Promise<string[]> {
|
||||
const content = await readFile(filePath, 'utf-8');
|
||||
return splitJsonlContent(content);
|
||||
}
|
||||
|
||||
export function readJsonlLinesSync(filePath: string): string[] {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
return splitJsonlContent(content);
|
||||
}
|
||||
|
||||
export function parseJsonlLine(line: string): unknown {
|
||||
try {
|
||||
return JSON.parse(line) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
import type {
|
||||
TokenMetrics,
|
||||
TranscriptLine
|
||||
} from '../types';
|
||||
|
||||
import {
|
||||
parseJsonlLine,
|
||||
readJsonlLines
|
||||
} from './jsonl-lines';
|
||||
|
||||
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let firstTimestamp: Date | null = null;
|
||||
let lastTimestamp: Date | null = null;
|
||||
|
||||
// Find first valid timestamp
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as { timestamp?: string } | null;
|
||||
if (data?.timestamp) {
|
||||
firstTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find last valid timestamp (iterate backwards)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i];
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = parseJsonlLine(line) as { timestamp?: string } | null;
|
||||
if (data?.timestamp) {
|
||||
lastTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstTimestamp || !lastTimestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate duration in milliseconds
|
||||
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
|
||||
|
||||
// Convert to minutes
|
||||
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`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours}hr`;
|
||||
} else {
|
||||
return `${hours}hr ${minutes}m`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
|
||||
try {
|
||||
// Use Node.js-compatible file reading
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
|
||||
const lines = await readJsonlLines(transcriptPath);
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let contextLength = 0;
|
||||
|
||||
// Parse each line and sum up token usage for totals
|
||||
let mostRecentMainChainEntry: TranscriptLine | null = null;
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const data = parseJsonlLine(line) as TranscriptLine | null;
|
||||
if (data?.message?.usage) {
|
||||
inputTokens += data.message.usage.input_tokens || 0;
|
||||
outputTokens += data.message.usage.output_tokens || 0;
|
||||
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
|
||||
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
|
||||
// Also skip API error messages (synthetic messages with 0 tokens)
|
||||
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
|
||||
const entryTime = new Date(data.timestamp);
|
||||
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
|
||||
mostRecentTimestamp = entryTime;
|
||||
mostRecentMainChainEntry = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate context length from the most recent main chain message
|
||||
if (mostRecentMainChainEntry?.message?.usage) {
|
||||
const usage = mostRecentMainChainEntry.message.usage;
|
||||
contextLength = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens ?? 0)
|
||||
+ (usage.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cachedTokens;
|
||||
|
||||
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
|
||||
} catch {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
}
|
||||
+11
-496
@@ -1,496 +1,11 @@
|
||||
import * as fs from 'fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { globSync } from 'tinyglobby';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import type {
|
||||
BlockMetrics,
|
||||
TokenMetrics,
|
||||
TranscriptLine
|
||||
} from '../types';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
|
||||
// Ensure fs.promises compatibility for older Node versions
|
||||
const readFile = promisify(fs.readFile);
|
||||
const readFileSync = fs.readFileSync;
|
||||
const statSync = fs.statSync;
|
||||
const writeFileSync = fs.writeFileSync;
|
||||
const mkdirSync = fs.mkdirSync;
|
||||
const existsSync = fs.existsSync;
|
||||
|
||||
// --- Block Cache Functions ---
|
||||
|
||||
interface BlockCache {
|
||||
startTime: string;
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
function normalizeConfigDir(configDir: string): string {
|
||||
return path.resolve(configDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the block cache file for a specific Claude config directory
|
||||
*/
|
||||
export function getBlockCachePath(configDir = getClaudeConfigDir()): string {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const configHash = createHash('sha256')
|
||||
.update(normalizedConfigDir)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
'.cache',
|
||||
'ccstatusline',
|
||||
`block-cache-${configHash}.json`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the block cache file and returns the cached start time
|
||||
* Returns null if cache doesn't exist or is invalid
|
||||
*/
|
||||
export function readBlockCache(expectedConfigDir?: string): Date | null {
|
||||
try {
|
||||
const normalizedExpectedConfigDir = expectedConfigDir !== undefined
|
||||
? normalizeConfigDir(expectedConfigDir)
|
||||
: undefined;
|
||||
const cachePath = getBlockCachePath(normalizedExpectedConfigDir);
|
||||
if (!existsSync(cachePath)) {
|
||||
return null;
|
||||
}
|
||||
const content = readFileSync(cachePath, 'utf-8');
|
||||
const cache = JSON.parse(content) as BlockCache;
|
||||
if (typeof cache.startTime !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (normalizedExpectedConfigDir !== undefined) {
|
||||
if (typeof cache.configDir !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (cache.configDir !== normalizedExpectedConfigDir) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const date = new Date(cache.startTime);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the block start time to the cache file
|
||||
* Creates the cache directory if it doesn't exist
|
||||
*/
|
||||
export function writeBlockCache(startTime: Date, configDir = getClaudeConfigDir()): void {
|
||||
try {
|
||||
const normalizedConfigDir = normalizeConfigDir(configDir);
|
||||
const cachePath = getBlockCachePath(normalizedConfigDir);
|
||||
const cacheDir = path.dirname(cachePath);
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true });
|
||||
}
|
||||
const cache: BlockCache = {
|
||||
startTime: startTime.toISOString(),
|
||||
configDir: normalizedConfigDir
|
||||
};
|
||||
writeFileSync(cachePath, JSON.stringify(cache), 'utf-8');
|
||||
} catch {
|
||||
// Silently fail - caching is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics with caching support
|
||||
* Returns cached result if still valid, otherwise recalculates
|
||||
*/
|
||||
export function getCachedBlockMetrics(sessionDurationHours = 5): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
const activeConfigDir = getClaudeConfigDir();
|
||||
|
||||
// Check cache first
|
||||
const cachedStartTime = readBlockCache(activeConfigDir);
|
||||
if (cachedStartTime) {
|
||||
const blockEndTime = new Date(cachedStartTime.getTime() + sessionDurationMs);
|
||||
if (now.getTime() <= blockEndTime.getTime()) {
|
||||
// Cache is valid - return cached result
|
||||
return {
|
||||
startTime: cachedStartTime,
|
||||
lastActivity: now // We don't cache lastActivity, use current time
|
||||
};
|
||||
}
|
||||
// Cache expired - need to recalculate
|
||||
}
|
||||
|
||||
// Cache miss or expired - run full calculation
|
||||
const metrics = getBlockMetrics();
|
||||
|
||||
// Write to cache if we found a valid block
|
||||
if (metrics) {
|
||||
writeBlockCache(metrics.startTime, activeConfigDir);
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
export async function getSessionDuration(transcriptPath: string): Promise<string | null> {
|
||||
try {
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter((line: string) => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let firstTimestamp: Date | null = null;
|
||||
let lastTimestamp: Date | null = null;
|
||||
|
||||
// Find first valid timestamp
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line) as { timestamp?: string };
|
||||
if (data.timestamp) {
|
||||
firstTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
// Find last valid timestamp (iterate backwards)
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
const data = JSON.parse(lines[i] ?? '') as { timestamp?: string };
|
||||
if (data.timestamp) {
|
||||
lastTimestamp = new Date(data.timestamp);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid lines
|
||||
}
|
||||
}
|
||||
|
||||
if (!firstTimestamp || !lastTimestamp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate duration in milliseconds
|
||||
const durationMs = lastTimestamp.getTime() - firstTimestamp.getTime();
|
||||
|
||||
// Convert to minutes
|
||||
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`;
|
||||
} else if (minutes === 0) {
|
||||
return `${hours}hr`;
|
||||
} else {
|
||||
return `${hours}hr ${minutes}m`;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTokenMetrics(transcriptPath: string): Promise<TokenMetrics> {
|
||||
try {
|
||||
// Use Node.js-compatible file reading
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
|
||||
const content = await readFile(transcriptPath, 'utf-8');
|
||||
const lines = content.trim().split('\n');
|
||||
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
let cachedTokens = 0;
|
||||
let contextLength = 0;
|
||||
|
||||
// Parse each line and sum up token usage for totals
|
||||
let mostRecentMainChainEntry: TranscriptLine | null = null;
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line) as TranscriptLine;
|
||||
if (data.message?.usage) {
|
||||
inputTokens += data.message.usage.input_tokens || 0;
|
||||
outputTokens += data.message.usage.output_tokens || 0;
|
||||
cachedTokens += data.message.usage.cache_read_input_tokens ?? 0;
|
||||
cachedTokens += data.message.usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
// Track the most recent entry with isSidechain: false (or undefined, which defaults to main chain)
|
||||
// Also skip API error messages (synthetic messages with 0 tokens)
|
||||
if (data.isSidechain !== true && data.timestamp && !data.isApiErrorMessage) {
|
||||
const entryTime = new Date(data.timestamp);
|
||||
if (!mostRecentTimestamp || entryTime > mostRecentTimestamp) {
|
||||
mostRecentTimestamp = entryTime;
|
||||
mostRecentMainChainEntry = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate context length from the most recent main chain message
|
||||
if (mostRecentMainChainEntry?.message?.usage) {
|
||||
const usage = mostRecentMainChainEntry.message.usage;
|
||||
contextLength = (usage.input_tokens || 0)
|
||||
+ (usage.cache_read_input_tokens ?? 0)
|
||||
+ (usage.cache_creation_input_tokens ?? 0);
|
||||
}
|
||||
|
||||
const totalTokens = inputTokens + outputTokens + cachedTokens;
|
||||
|
||||
return { inputTokens, outputTokens, cachedTokens, totalTokens, contextLength };
|
||||
} catch {
|
||||
return { inputTokens: 0, outputTokens: 0, cachedTokens: 0, totalTokens: 0, contextLength: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets block metrics for the current 5-hour block from JSONL files
|
||||
*/
|
||||
export function getBlockMetrics(): BlockMetrics | null {
|
||||
const claudeDir: string | null = getClaudeConfigDir();
|
||||
|
||||
if (!claudeDir)
|
||||
return null;
|
||||
|
||||
try {
|
||||
return findMostRecentBlockStartTime(claudeDir);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Efficiently finds the most recent 5-hour block start time from JSONL files
|
||||
* Uses file modification times as hints to avoid unnecessary reads
|
||||
*/
|
||||
function findMostRecentBlockStartTime(
|
||||
rootDir: string,
|
||||
sessionDurationHours = 5
|
||||
): BlockMetrics | null {
|
||||
const sessionDurationMs = sessionDurationHours * 60 * 60 * 1000;
|
||||
const now = new Date();
|
||||
|
||||
// Step 1: Find all JSONL files with their modification times
|
||||
// Use forward slashes for glob patterns on all platforms (tinyglobby requirement)
|
||||
const pattern = path.posix.join(rootDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl');
|
||||
const files = globSync([pattern], {
|
||||
absolute: true, // Ensure we get absolute paths
|
||||
cwd: rootDir // Set working directory to rootDir
|
||||
});
|
||||
|
||||
if (files.length === 0)
|
||||
return null;
|
||||
|
||||
// Step 2: Get file stats and sort by modification time (most recent first)
|
||||
const filesWithStats = files.map((file) => {
|
||||
const stats = statSync(file);
|
||||
return { file, mtime: stats.mtime };
|
||||
});
|
||||
|
||||
filesWithStats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
||||
|
||||
// Step 3: Progressive lookback - start small and expand if needed
|
||||
// Start with 2x session duration (10 hours), expand to 48 hours if needed
|
||||
const lookbackChunks = [
|
||||
10, // 2x session duration - catches most cases
|
||||
20, // 4x session duration - catches longer sessions
|
||||
48 // Maximum lookback for marathon sessions
|
||||
];
|
||||
|
||||
let timestamps: Date[] = [];
|
||||
let mostRecentTimestamp: Date | null = null;
|
||||
let continuousWorkStart: Date | null = null;
|
||||
let foundSessionGap = false;
|
||||
|
||||
for (const lookbackHours of lookbackChunks) {
|
||||
const cutoffTime = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
|
||||
timestamps = [];
|
||||
|
||||
// Collect timestamps for this lookback period
|
||||
for (const { file, mtime } of filesWithStats) {
|
||||
if (mtime.getTime() < cutoffTime.getTime()) {
|
||||
break;
|
||||
}
|
||||
const fileTimestamps = getAllTimestampsFromFile(file);
|
||||
timestamps.push(...fileTimestamps);
|
||||
}
|
||||
|
||||
if (timestamps.length === 0) {
|
||||
continue; // Try next chunk
|
||||
}
|
||||
|
||||
// Sort timestamps (most recent first)
|
||||
timestamps.sort((a, b) => b.getTime() - a.getTime());
|
||||
|
||||
// Get most recent timestamp (only set once)
|
||||
if (!mostRecentTimestamp && timestamps[0]) {
|
||||
mostRecentTimestamp = timestamps[0];
|
||||
|
||||
// Check if the most recent activity is within the current session period
|
||||
const timeSinceLastActivity = now.getTime() - mostRecentTimestamp.getTime();
|
||||
if (timeSinceLastActivity > sessionDurationMs) {
|
||||
// No activity within the current session period
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Look for a session gap in this chunk
|
||||
continuousWorkStart = mostRecentTimestamp;
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const currentTimestamp = timestamps[i];
|
||||
const previousTimestamp = timestamps[i - 1];
|
||||
|
||||
if (!currentTimestamp || !previousTimestamp)
|
||||
continue;
|
||||
|
||||
const gap = previousTimestamp.getTime() - currentTimestamp.getTime();
|
||||
|
||||
if (gap >= sessionDurationMs) {
|
||||
// Found a true session boundary
|
||||
foundSessionGap = true;
|
||||
break;
|
||||
}
|
||||
|
||||
continuousWorkStart = currentTimestamp;
|
||||
}
|
||||
|
||||
// If we found a gap, we're done
|
||||
if (foundSessionGap) {
|
||||
break;
|
||||
}
|
||||
|
||||
// If this was our last chunk, use what we have
|
||||
if (lookbackHours === lookbackChunks[lookbackChunks.length - 1]) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mostRecentTimestamp || !continuousWorkStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build actual blocks from timestamps going forward
|
||||
const blocks: { start: Date; end: Date }[] = [];
|
||||
const sortedTimestamps = timestamps.slice().sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
let currentBlockStart: Date | null = null;
|
||||
let currentBlockEnd: Date | null = null;
|
||||
|
||||
for (const timestamp of sortedTimestamps) {
|
||||
if (timestamp.getTime() < continuousWorkStart.getTime())
|
||||
continue;
|
||||
|
||||
if (!currentBlockStart || (currentBlockEnd && timestamp.getTime() > currentBlockEnd.getTime())) {
|
||||
// Start new block
|
||||
currentBlockStart = floorToHour(timestamp);
|
||||
currentBlockEnd = new Date(currentBlockStart.getTime() + sessionDurationMs);
|
||||
blocks.push({ start: currentBlockStart, end: currentBlockEnd });
|
||||
}
|
||||
}
|
||||
|
||||
// Find current block
|
||||
for (const block of blocks) {
|
||||
if (now.getTime() >= block.start.getTime() && now.getTime() <= block.end.getTime()) {
|
||||
// Verify we have activity in this block
|
||||
const hasActivity = timestamps.some(t => t.getTime() >= block.start.getTime()
|
||||
&& t.getTime() <= block.end.getTime()
|
||||
);
|
||||
|
||||
if (hasActivity) {
|
||||
return {
|
||||
startTime: block.start,
|
||||
lastActivity: mostRecentTimestamp
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all timestamps from a JSONL file
|
||||
*/
|
||||
function getAllTimestampsFromFile(filePath: string): Date[] {
|
||||
const timestamps: Date[] = [];
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
const lines = content.trim().split('\n').filter(line => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line) as {
|
||||
timestamp?: string;
|
||||
isSidechain?: boolean;
|
||||
message?: { usage?: { input_tokens?: number; output_tokens?: number } };
|
||||
};
|
||||
|
||||
// Only treat entries with real token usage as block activity
|
||||
const usage = json.message?.usage;
|
||||
if (!usage)
|
||||
continue;
|
||||
|
||||
const hasInputTokens = typeof usage.input_tokens === 'number';
|
||||
const hasOutputTokens = typeof usage.output_tokens === 'number';
|
||||
if (!hasInputTokens || !hasOutputTokens)
|
||||
continue;
|
||||
|
||||
if (json.isSidechain === true)
|
||||
continue;
|
||||
|
||||
const timestamp = json.timestamp;
|
||||
if (typeof timestamp !== 'string')
|
||||
continue;
|
||||
|
||||
const date = new Date(timestamp);
|
||||
if (!Number.isNaN(date.getTime()))
|
||||
timestamps.push(date);
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return timestamps;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Floors a timestamp to the beginning of the hour (matching existing logic)
|
||||
*/
|
||||
function floorToHour(timestamp: Date): Date {
|
||||
const floored = new Date(timestamp);
|
||||
floored.setUTCMinutes(0, 0, 0);
|
||||
return floored;
|
||||
}
|
||||
export {
|
||||
getBlockCachePath,
|
||||
getCachedBlockMetrics,
|
||||
readBlockCache,
|
||||
writeBlockCache
|
||||
} from './jsonl-cache';
|
||||
export { getBlockMetrics } from './jsonl-blocks';
|
||||
export {
|
||||
getSessionDuration,
|
||||
getTokenMetrics
|
||||
} from './jsonl-metrics';
|
||||
@@ -0,0 +1,321 @@
|
||||
import { execSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as https from 'https';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getClaudeConfigDir } from './claude-settings';
|
||||
import type {
|
||||
UsageData,
|
||||
UsageError
|
||||
} from './usage-types';
|
||||
import { UsageErrorSchema } from './usage-types';
|
||||
|
||||
// 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
|
||||
|
||||
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(),
|
||||
weeklyResetAt: z.string().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(),
|
||||
resets_at: z.string().nullable().optional()
|
||||
}).optional(),
|
||||
seven_day: z.object({
|
||||
utilization: z.number().nullable().optional(),
|
||||
resets_at: z.string().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,
|
||||
weeklyResetAt: parsed.weeklyResetAt ?? 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 ?? undefined,
|
||||
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
|
||||
weeklyResetAt: parsed.seven_day?.resets_at ?? 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;
|
||||
}
|
||||
}
|
||||
|
||||
const USAGE_API_HOST = 'api.anthropic.com';
|
||||
const USAGE_API_PATH = '/api/oauth/usage';
|
||||
|
||||
async function fetchFromUsageApi(token: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (value: string | null) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
const request = https.request({
|
||||
hostname: USAGE_API_HOST,
|
||||
path: USAGE_API_PATH,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'anthropic-beta': 'oauth-2025-04-20'
|
||||
},
|
||||
timeout: 5000
|
||||
}, (response) => {
|
||||
let data = '';
|
||||
response.setEncoding('utf8');
|
||||
|
||||
response.on('data', (chunk: string) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
if (response.statusCode === 200 && data) {
|
||||
finish(data);
|
||||
return;
|
||||
}
|
||||
finish(null);
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', () => { finish(null); });
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
finish(null);
|
||||
});
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchUsageData(): Promise<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 = await 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { WidgetItem } from '../types/Widget';
|
||||
|
||||
import type { UsageData } from './usage';
|
||||
import { fetchUsageData } from './usage';
|
||||
|
||||
const USAGE_WIDGET_TYPES = new Set<string>([
|
||||
'session-usage',
|
||||
'weekly-usage',
|
||||
'block-timer',
|
||||
'reset-timer',
|
||||
'weekly-reset-timer'
|
||||
]);
|
||||
|
||||
export function hasUsageDependentWidgets(lines: WidgetItem[][]): boolean {
|
||||
return lines.some(line => line.some(item => USAGE_WIDGET_TYPES.has(item.type)));
|
||||
}
|
||||
|
||||
export async function prefetchUsageDataIfNeeded(lines: WidgetItem[][]): Promise<UsageData | null> {
|
||||
if (!hasUsageDependentWidgets(lines)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await fetchUsageData();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const FIVE_HOUR_BLOCK_MS = 5 * 60 * 60 * 1000;
|
||||
export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export 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.resets_at
|
||||
weeklyUsage?: number; // seven_day.utilization (percentage)
|
||||
weeklyResetAt?: string; // seven_day.resets_at
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { BlockMetrics } from '../types';
|
||||
|
||||
import { getCachedBlockMetrics } from './jsonl';
|
||||
import {
|
||||
FIVE_HOUR_BLOCK_MS,
|
||||
SEVEN_DAY_WINDOW_MS,
|
||||
type UsageData,
|
||||
type UsageError,
|
||||
type UsageWindowMetrics
|
||||
} from './usage-types';
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function buildUsageWindow(resetAtMs: number, nowMs: number, durationMs: number): UsageWindowMetrics | null {
|
||||
if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startAtMs = resetAtMs - durationMs;
|
||||
const elapsedMs = clamp(nowMs - startAtMs, 0, durationMs);
|
||||
const remainingMs = durationMs - elapsedMs;
|
||||
const elapsedPercent = (elapsedMs / durationMs) * 100;
|
||||
|
||||
return {
|
||||
sessionDurationMs: durationMs,
|
||||
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, FIVE_HOUR_BLOCK_MS);
|
||||
}
|
||||
|
||||
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, FIVE_HOUR_BLOCK_MS);
|
||||
}
|
||||
|
||||
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 getWeeklyUsageWindowFromResetAt(weeklyResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
if (!weeklyResetAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetAtMs = Date.parse(weeklyResetAt);
|
||||
if (Number.isNaN(resetAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(resetAtMs, nowMs, SEVEN_DAY_WINDOW_MS);
|
||||
}
|
||||
|
||||
export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, 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) + ']';
|
||||
}
|
||||
+18
-458
@@ -1,458 +1,18 @@
|
||||
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;
|
||||
export const SEVEN_DAY_WINDOW_MS = 7 * 24 * 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.resets_at
|
||||
weeklyUsage?: number; // seven_day.utilization (percentage)
|
||||
weeklyResetAt?: string; // seven_day.resets_at
|
||||
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(),
|
||||
weeklyResetAt: z.string().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(),
|
||||
resets_at: z.string().nullable().optional()
|
||||
}).optional(),
|
||||
seven_day: z.object({
|
||||
utilization: z.number().nullable().optional(),
|
||||
resets_at: z.string().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,
|
||||
weeklyResetAt: parsed.weeklyResetAt ?? 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 ?? undefined,
|
||||
weeklyUsage: parsed.seven_day?.utilization ?? undefined,
|
||||
weeklyResetAt: parsed.seven_day?.resets_at ?? 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, durationMs: number): UsageWindowMetrics | null {
|
||||
if (!Number.isFinite(resetAtMs) || !Number.isFinite(nowMs) || !Number.isFinite(durationMs) || durationMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startAtMs = resetAtMs - durationMs;
|
||||
const elapsedMs = clamp(nowMs - startAtMs, 0, durationMs);
|
||||
const remainingMs = durationMs - elapsedMs;
|
||||
const elapsedPercent = (elapsedMs / durationMs) * 100;
|
||||
|
||||
return {
|
||||
sessionDurationMs: durationMs,
|
||||
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, FIVE_HOUR_BLOCK_MS);
|
||||
}
|
||||
|
||||
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, FIVE_HOUR_BLOCK_MS);
|
||||
}
|
||||
|
||||
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 getWeeklyUsageWindowFromResetAt(weeklyResetAt: string | undefined, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
if (!weeklyResetAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resetAtMs = Date.parse(weeklyResetAt);
|
||||
if (Number.isNaN(resetAtMs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return buildUsageWindow(resetAtMs, nowMs, SEVEN_DAY_WINDOW_MS);
|
||||
}
|
||||
|
||||
export function resolveWeeklyUsageWindow(usageData: UsageData, nowMs = Date.now()): UsageWindowMetrics | null {
|
||||
return getWeeklyUsageWindowFromResetAt(usageData.weeklyResetAt, 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) + ']';
|
||||
}
|
||||
export { fetchUsageData } from './usage-fetch';
|
||||
export {
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
getUsageWindowFromBlockMetrics,
|
||||
getUsageWindowFromResetAt,
|
||||
getWeeklyUsageWindowFromResetAt,
|
||||
makeUsageProgressBar,
|
||||
resolveUsageWindowWithFallback,
|
||||
resolveWeeklyUsageWindow
|
||||
} from './usage-windows';
|
||||
export {
|
||||
FIVE_HOUR_BLOCK_MS,
|
||||
SEVEN_DAY_WINDOW_MS,
|
||||
type UsageData,
|
||||
type UsageError,
|
||||
type UsageWindowMetrics
|
||||
} from './usage-types';
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
resolveUsageWindowWithFallback
|
||||
@@ -72,7 +71,7 @@ export class BlockResetTimerWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Reset: ', '4hr 30m');
|
||||
}
|
||||
|
||||
const usageData = fetchUsageData();
|
||||
const usageData = context.usageData ?? {};
|
||||
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
|
||||
|
||||
if (!window) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
resolveUsageWindowWithFallback
|
||||
} from '../utils/usage';
|
||||
@@ -71,7 +70,7 @@ export class BlockTimerWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Block: ', '3hr 45m');
|
||||
}
|
||||
|
||||
const usageData = fetchUsageData();
|
||||
const usageData = context.usageData ?? {};
|
||||
const window = resolveUsageWindowWithFallback(usageData, context.blockMetrics);
|
||||
|
||||
if (!window) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
getUsageErrorMessage,
|
||||
makeUsageProgressBar
|
||||
} from '../utils/usage';
|
||||
@@ -65,7 +64,7 @@ export class SessionUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Session: ', `${previewPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const data = fetchUsageData();
|
||||
const data = context.usageData ?? {};
|
||||
if (data.error)
|
||||
return getUsageErrorMessage(data.error);
|
||||
if (data.sessionUsage === undefined)
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
resolveWeeklyUsageWindow
|
||||
@@ -72,7 +71,7 @@ export class WeeklyResetTimerWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Weekly Reset: ', '36hr 30m');
|
||||
}
|
||||
|
||||
const usageData = fetchUsageData();
|
||||
const usageData = context.usageData ?? {};
|
||||
const window = resolveWeeklyUsageWindow(usageData);
|
||||
|
||||
if (!window) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
WidgetItem
|
||||
} from '../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
getUsageErrorMessage,
|
||||
makeUsageProgressBar
|
||||
} from '../utils/usage';
|
||||
@@ -65,7 +64,7 @@ export class WeeklyUsageWidget implements Widget {
|
||||
return formatRawOrLabeledValue(item, 'Weekly: ', `${previewPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
const data = fetchUsageData();
|
||||
const data = context.usageData ?? {};
|
||||
if (data.error)
|
||||
return getUsageErrorMessage(data.error);
|
||||
if (data.weeklyUsage === undefined)
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
resolveUsageWindowWithFallback
|
||||
@@ -18,13 +17,11 @@ import {
|
||||
import { BlockResetTimerWidget } from '../BlockResetTimer';
|
||||
|
||||
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 };
|
||||
@@ -58,7 +55,6 @@ describe('BlockResetTimerWidget', () => {
|
||||
it('renders remaining time in time mode', () => {
|
||||
const widget = new BlockResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 3600000,
|
||||
@@ -68,7 +64,7 @@ describe('BlockResetTimerWidget', () => {
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('4hr');
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBe('Reset: 4hr');
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: {} })).toBe('Reset: 4hr');
|
||||
});
|
||||
|
||||
it('renders short progress bar with inverted fill', () => {
|
||||
@@ -82,7 +78,6 @@ describe('BlockResetTimerWidget', () => {
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 14400000,
|
||||
@@ -91,32 +86,29 @@ describe('BlockResetTimerWidget', () => {
|
||||
remainingPercent: 20
|
||||
});
|
||||
|
||||
expect(render(widget, item)).toBe('Reset [███░░░░░░░░░░░░░] 20.0%');
|
||||
expect(render(widget, item, { usageData: {} })).toBe('Reset [███░░░░░░░░░░░░░] 20.0%');
|
||||
});
|
||||
|
||||
it('returns usage error when no timer data is available', () => {
|
||||
const widget = new BlockResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ error: 'timeout' });
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue(null);
|
||||
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBe('[Timeout]');
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('returns null when neither timer data nor usage error exists', () => {
|
||||
const widget = new BlockResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue(null);
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' })).toBeNull();
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer' }, { usageData: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows raw value without label in time mode', () => {
|
||||
const widget = new BlockResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 4500000,
|
||||
@@ -126,7 +118,7 @@ describe('BlockResetTimerWidget', () => {
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('3hr 45m');
|
||||
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true })).toBe('3hr 45m');
|
||||
expect(render(widget, { id: 'reset', type: 'reset-timer', rawValue: true }, { usageData: {} })).toBe('3hr 45m');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
|
||||
@@ -10,19 +10,16 @@ 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 };
|
||||
|
||||
@@ -49,7 +46,6 @@ describe('BlockTimerWidget', () => {
|
||||
const widget = new BlockTimerWidget();
|
||||
const item: WidgetItem = { id: 'block', type: 'block-timer' };
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 13500000,
|
||||
@@ -59,7 +55,7 @@ describe('BlockTimerWidget', () => {
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('3hr 45m');
|
||||
|
||||
expect(render(widget, item)).toBe('Block: 3hr 45m');
|
||||
expect(render(widget, item, { usageData: {} })).toBe('Block: 3hr 45m');
|
||||
});
|
||||
|
||||
it('renders short progress bar with inverted fill', () => {
|
||||
@@ -73,7 +69,6 @@ describe('BlockTimerWidget', () => {
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveUsageWindowWithFallback.mockReturnValue({
|
||||
sessionDurationMs: 18000000,
|
||||
elapsedMs: 13500000,
|
||||
@@ -82,27 +77,25 @@ describe('BlockTimerWidget', () => {
|
||||
remainingPercent: 25
|
||||
});
|
||||
|
||||
expect(render(widget, item)).toBe('Block [████░░░░░░░░░░░░] 25.0%');
|
||||
expect(render(widget, item, { usageData: {} })).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' }, { usageData: { error: 'timeout' } })).toBe('Block: 0hr 0m');
|
||||
expect(render(widget, {
|
||||
id: 'block',
|
||||
type: 'block-timer',
|
||||
metadata: { display: 'progress' }
|
||||
})).toBe(`Block [${'░'.repeat(32)}] 0.0%`);
|
||||
}, { usageData: { error: 'timeout' } })).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,
|
||||
@@ -112,7 +105,7 @@ describe('BlockTimerWidget', () => {
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('2hr');
|
||||
|
||||
expect(render(widget, { id: 'block', type: 'block-timer', rawValue: true })).toBe('2hr');
|
||||
expect(render(widget, { id: 'block', type: 'block-timer', rawValue: true }, { usageData: {} })).toBe('2hr');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
|
||||
@@ -9,19 +9,14 @@ import {
|
||||
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 { 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 {
|
||||
@@ -45,10 +40,7 @@ describe('SessionUsageWidget', () => {
|
||||
|
||||
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%');
|
||||
expect(render(widget, { id: 'session', type: 'session-usage' }, { usageData: { sessionUsage: 23.45 } })).toBe('Session: 23.4%');
|
||||
});
|
||||
|
||||
it('renders short inverted progress mode', () => {
|
||||
@@ -62,17 +54,13 @@ describe('SessionUsageWidget', () => {
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 });
|
||||
|
||||
expect(render(widget, item)).toBe('Session: [bar:76.5:16] 76.5%');
|
||||
expect(render(widget, item, { usageData: { sessionUsage: 23.45 } })).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%');
|
||||
expect(render(widget, { id: 'session', type: 'session-usage', rawValue: true }, { usageData: { sessionUsage: 23.45 } })).toBe('23.4%');
|
||||
});
|
||||
|
||||
it('renders raw progress mode without label', () => {
|
||||
@@ -84,18 +72,15 @@ describe('SessionUsageWidget', () => {
|
||||
metadata: { display: 'progress' }
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ sessionUsage: 23.45 });
|
||||
|
||||
expect(render(widget, item)).toBe('[bar:23.4:32] 23.4%');
|
||||
expect(render(widget, item, { usageData: { sessionUsage: 23.45 } })).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]');
|
||||
expect(render(widget, { id: 'session', type: 'session-usage' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { RenderContext } from '../../types/RenderContext';
|
||||
import { DEFAULT_SETTINGS } from '../../types/Settings';
|
||||
import type { WidgetItem } from '../../types/Widget';
|
||||
import {
|
||||
fetchUsageData,
|
||||
formatUsageDuration,
|
||||
getUsageErrorMessage,
|
||||
resolveWeeklyUsageWindow
|
||||
@@ -18,13 +17,11 @@ import {
|
||||
import { WeeklyResetTimerWidget } from '../WeeklyResetTimer';
|
||||
|
||||
vi.mock('../../utils/usage', () => ({
|
||||
fetchUsageData: vi.fn(),
|
||||
formatUsageDuration: vi.fn(),
|
||||
getUsageErrorMessage: vi.fn(),
|
||||
resolveWeeklyUsageWindow: 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 mockResolveWeeklyUsageWindow = resolveWeeklyUsageWindow as unknown as { mockReturnValue: (value: unknown) => void };
|
||||
@@ -58,7 +55,6 @@ describe('WeeklyResetTimerWidget', () => {
|
||||
it('renders remaining time in time mode', () => {
|
||||
const widget = new WeeklyResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveWeeklyUsageWindow.mockReturnValue({
|
||||
sessionDurationMs: 604800000,
|
||||
elapsedMs: 120000000,
|
||||
@@ -68,7 +64,7 @@ describe('WeeklyResetTimerWidget', () => {
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('134hr 40m');
|
||||
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBe('Weekly Reset: 134hr 40m');
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: {} })).toBe('Weekly Reset: 134hr 40m');
|
||||
});
|
||||
|
||||
it('renders short progress bar with inverted fill', () => {
|
||||
@@ -82,7 +78,6 @@ describe('WeeklyResetTimerWidget', () => {
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveWeeklyUsageWindow.mockReturnValue({
|
||||
sessionDurationMs: 604800000,
|
||||
elapsedMs: 483840000,
|
||||
@@ -91,32 +86,29 @@ describe('WeeklyResetTimerWidget', () => {
|
||||
remainingPercent: 20
|
||||
});
|
||||
|
||||
expect(render(widget, item)).toBe('Weekly Reset [███░░░░░░░░░░░░░] 20.0%');
|
||||
expect(render(widget, item, { usageData: {} })).toBe('Weekly Reset [███░░░░░░░░░░░░░] 20.0%');
|
||||
});
|
||||
|
||||
it('returns usage error when no weekly reset data is available', () => {
|
||||
const widget = new WeeklyResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ error: 'timeout' });
|
||||
mockResolveWeeklyUsageWindow.mockReturnValue(null);
|
||||
mockGetUsageErrorMessage.mockReturnValue('[Timeout]');
|
||||
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBe('[Timeout]');
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('returns null when neither weekly reset data nor usage error exists', () => {
|
||||
const widget = new WeeklyResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveWeeklyUsageWindow.mockReturnValue(null);
|
||||
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' })).toBeNull();
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer' }, { usageData: {} })).toBeNull();
|
||||
});
|
||||
|
||||
it('shows raw value without label in time mode', () => {
|
||||
const widget = new WeeklyResetTimerWidget();
|
||||
|
||||
mockFetchUsageData.mockReturnValue({});
|
||||
mockResolveWeeklyUsageWindow.mockReturnValue({
|
||||
sessionDurationMs: 604800000,
|
||||
elapsedMs: 171900000,
|
||||
@@ -126,7 +118,7 @@ describe('WeeklyResetTimerWidget', () => {
|
||||
});
|
||||
mockFormatUsageDuration.mockReturnValue('120hr 15m');
|
||||
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true })).toBe('120hr 15m');
|
||||
expect(render(widget, { id: 'weekly-reset', type: 'weekly-reset-timer', rawValue: true }, { usageData: {} })).toBe('120hr 15m');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
|
||||
@@ -9,19 +9,14 @@ import {
|
||||
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 { 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 {
|
||||
@@ -46,9 +41,7 @@ describe('WeeklyUsageWidget', () => {
|
||||
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%');
|
||||
expect(render(widget, { id: 'weekly', type: 'weekly-usage' }, { usageData: { weeklyUsage: 42.06 } })).toBe('Weekly: 42.1%');
|
||||
});
|
||||
|
||||
it('renders full inverted progress mode', () => {
|
||||
@@ -62,17 +55,13 @@ describe('WeeklyUsageWidget', () => {
|
||||
}
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 });
|
||||
|
||||
expect(render(widget, item)).toBe('Weekly: [bar:57.9:32] 57.9%');
|
||||
expect(render(widget, item, { usageData: { weeklyUsage: 42.06 } })).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%');
|
||||
expect(render(widget, { id: 'weekly', type: 'weekly-usage', rawValue: true }, { usageData: { weeklyUsage: 42.06 } })).toBe('42.1%');
|
||||
});
|
||||
|
||||
it('renders raw progress mode without label', () => {
|
||||
@@ -84,18 +73,15 @@ describe('WeeklyUsageWidget', () => {
|
||||
metadata: { display: 'progress-short' }
|
||||
};
|
||||
|
||||
mockFetchUsageData.mockReturnValue({ weeklyUsage: 42.06 });
|
||||
|
||||
expect(render(widget, item)).toBe('[bar:42.1:16] 42.1%');
|
||||
expect(render(widget, item, { usageData: { weeklyUsage: 42.06 } })).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]');
|
||||
expect(render(widget, { id: 'weekly', type: 'weekly-usage' }, { usageData: { error: 'timeout' } })).toBe('[Timeout]');
|
||||
});
|
||||
|
||||
it('clears invert metadata when cycling back to time mode', () => {
|
||||
|
||||
Reference in new issue
Block a user