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