diff --git a/ROB-SETUP-GUIDE.md b/ROB-SETUP-GUIDE.md new file mode 100644 index 0000000..1f0c94a --- /dev/null +++ b/ROB-SETUP-GUIDE.md @@ -0,0 +1,123 @@ +# Rob's ccstatusline Setup Guide + +This guide shows you how to configure ccstatusline to match my exact setup: a single-line status bar showing the model name, context length, block/session usage with cost estimates and reset timers, and weekly usage with reset day. + +**What it looks like:** + +``` +Claude Opus 4.6 | 42.3k | Block: 18.2% (~$14/$89) resets in 3hr 45m | Weekly: 12.0% resets Friday +``` + +## Prerequisites + +This setup uses API usage widgets that require a Claude Pro/Max subscription (they read from the Anthropic usage API using your OAuth credentials). + +## Step 1: Install from this fork + +```bash +# Clone the fork (has the API usage widgets) +git clone https://github.com/robjampar/ccstatusline.git +cd ccstatusline +bun install +``` + +## Step 2: Configure Claude Code to use it + +Add this to your `~/.claude/settings.json`: + +```json +{ + "statusLine": "bun run /path/to/ccstatusline/src/ccstatusline.ts" +} +``` + +Replace `/path/to/ccstatusline` with the actual path where you cloned the repo. + +## Step 3: Apply the settings + +Copy this JSON to `~/.config/ccstatusline/settings.json`: + +```json +{ + "version": 3, + "lines": [ + [ + { + "id": "1", + "type": "model", + "color": "cyan", + "rawValue": true + }, + { + "id": "2", + "type": "separator" + }, + { + "id": "3", + "type": "context-length", + "color": "brightBlack" + }, + { + "id": "4", + "type": "separator" + }, + { + "id": "su1", + "type": "session-usage", + "color": "yellow", + "rawValue": true + }, + { + "id": "5", + "type": "separator" + }, + { + "id": "wu1", + "type": "weekly-usage", + "color": "cyan", + "rawValue": true + } + ] + ], + "flexMode": "full-minus-40", + "compactThreshold": 60, + "colorLevel": 2, + "inheritSeparatorColors": false, + "globalBold": false, + "powerline": { + "enabled": false, + "separators": [""], + "separatorInvertBackground": [false], + "startCaps": [], + "endCaps": [], + "autoAlign": false + } +} +``` + +## Widget breakdown + +| Widget | Type | Color | Raw Value | What it shows | +|--------|------|-------|-----------|---------------| +| Model | `model` | cyan | yes | Model name without label, e.g. `Claude Opus 4.6` | +| Context Length | `context-length` | brightBlack (gray) | no | Current context token count, e.g. `Ctx: 42.3k` | +| Session Usage | `session-usage` | yellow | yes | Block usage %, estimated cost, and reset countdown, e.g. `Block: 18.2% (~$14/$89) resets in 3hr 45m` | +| Weekly Usage | `weekly-usage` | cyan | yes | Weekly usage % and reset day, e.g. `Weekly: 12.0% resets Friday` | + +### Key settings + +- **`rawValue: true`** on model removes the "Model:" label prefix +- **`rawValue: true`** on session-usage switches from a progress bar to the compact text format with cost estimates and reset timer +- **`rawValue: true`** on weekly-usage switches from a progress bar to the compact text format with reset day +- **`flexMode: full-minus-40`** reserves space so the auto-compact message doesn't wrap +- **`colorLevel: 2`** enables 256-color mode + +## Customizing further + +Run the TUI to interactively adjust widgets, colors, and layout: + +```bash +bun run /path/to/ccstatusline/src/ccstatusline.ts +``` + +(Run it without piped input to get the interactive configuration UI.) diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 0bc999d..a1b7917 100644 --- a/src/ccstatusline.ts +++ b/src/ccstatusline.ts @@ -4,6 +4,7 @@ import chalk from 'chalk'; import { runTUI } from './tui'; import type { BlockMetrics, + BlockTokenMetrics, TokenMetrics } from './types'; import type { RenderContext } from './types/RenderContext'; @@ -16,6 +17,7 @@ import { } from './utils/config'; import { getBlockMetrics, + getBlockTokenMetrics, getSessionDuration, getTokenMetrics } from './utils/jsonl'; @@ -72,8 +74,9 @@ async function renderMultipleLines(data: StatusJSON) { // 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')); + // Check if block timer or block usage is needed + const hasBlockTimer = lines.some(line => line.some(item => item.type === 'block-timer' || item.type === 'block-usage')); + const hasBlockUsage = lines.some(line => line.some(item => item.type === 'block-usage')); let tokenMetrics: TokenMetrics | null = null; if (hasTokenItems && data.transcript_path) { @@ -90,12 +93,18 @@ async function renderMultipleLines(data: StatusJSON) { blockMetrics = getBlockMetrics(); } + let blockTokenMetrics: BlockTokenMetrics | null = null; + if (hasBlockUsage && blockMetrics) { + blockTokenMetrics = getBlockTokenMetrics(blockMetrics); + } + // Create render context const context: RenderContext = { data, tokenMetrics, sessionDuration, blockMetrics, + blockTokenMetrics, isPreview: false }; diff --git a/src/types/RenderContext.ts b/src/types/RenderContext.ts index 9e088c9..ac553cc 100644 --- a/src/types/RenderContext.ts +++ b/src/types/RenderContext.ts @@ -1,13 +1,17 @@ import type { BlockMetrics } from '../types'; import type { StatusJSON } from './StatusJSON'; -import type { TokenMetrics } from './TokenMetrics'; +import type { + BlockTokenMetrics, + TokenMetrics +} from './TokenMetrics'; export interface RenderContext { data?: StatusJSON; tokenMetrics?: TokenMetrics | null; sessionDuration?: string | null; blockMetrics?: BlockMetrics | null; + blockTokenMetrics?: BlockTokenMetrics | null; terminalWidth?: number | null; isPreview?: boolean; lineIndex?: number; // Index of the current line being rendered (for theme cycling) 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/types/TokenMetrics.ts b/src/types/TokenMetrics.ts index ec3c866..fbee9fe 100644 --- a/src/types/TokenMetrics.ts +++ b/src/types/TokenMetrics.ts @@ -6,7 +6,7 @@ export interface TokenUsage { } export interface TranscriptLine { - message?: { usage?: TokenUsage }; + message?: { model?: string; usage?: TokenUsage }; isSidechain?: boolean; timestamp?: string; isApiErrorMessage?: boolean; @@ -18,4 +18,18 @@ export interface TokenMetrics { cachedTokens: number; totalTokens: number; contextLength: number; +} + +export interface BlockTokenMetrics { + inputTokens: number; + outputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + totalTokens: number; + readCostUsd: number; + writeCostUsd: number; + estimatedCostUsd: number; + estimatedMaxTokens: number; + estimatedMaxCostUsd: number; + isMaxEstimated: boolean; } \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 916ded0..12c43b2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -12,7 +12,7 @@ export type { PowerlineConfig } from './PowerlineConfig'; export type { ColorLevel, ColorLevelString } from './ColorLevel'; export { getColorLevelString } from './ColorLevel'; export type { StatusJSON } from './StatusJSON'; -export type { TokenMetrics, TokenUsage, TranscriptLine } from './TokenMetrics'; +export type { BlockTokenMetrics, TokenMetrics, TokenUsage, TranscriptLine } from './TokenMetrics'; export type { RenderContext } from './RenderContext'; export type { PowerlineFontStatus } from './PowerlineFontStatus'; export type { ClaudeSettings } from './ClaudeSettings'; diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index d00923c..335bd10 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -5,11 +5,13 @@ import { promisify } from 'util'; import type { BlockMetrics, + BlockTokenMetrics, TokenMetrics, TranscriptLine } from '../types'; import { getClaudeConfigDir } from './claude-settings'; +import { estimateCostUsd } from './pricing'; // Ensure fs.promises compatibility for older Node versions const readFile = promisify(fs.readFile); @@ -360,6 +362,290 @@ function getAllTimestampsFromFile(filePath: string): Date[] { } } +// Cache for block token metrics per file (keyed by filepath, invalidated by mtime) +const blockTokenCache = new Map(); + +// Cache for estimated max tokens (recomputed hourly) +const FALLBACK_MAX_TOKENS = 5_000_000; +const MIN_MAX_TOKENS = 1_000_000; +const MAX_CACHE_TTL_MS = 3600 * 1000; // 1 hour +let maxCache: { tokens: number; costUsd: number; isEstimated: boolean; computedAt: number } | null = null; + +/** + * Sums token usage from a single JSONL file for entries within the block timeframe. + * Results are cached by file path + mtime. + */ +function getFileBlockTokens(filePath: string, blockStart: Date, blockEnd: Date): BlockTokenMetrics { + const empty: BlockTokenMetrics = { + inputTokens: 0, outputTokens: 0, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 0, readCostUsd: 0, writeCostUsd: 0, estimatedCostUsd: 0, + estimatedMaxTokens: 0, estimatedMaxCostUsd: 0, isMaxEstimated: true + }; + + try { + const stats = statSync(filePath); + + // Skip files not modified since block start + if (stats.mtime.getTime() < blockStart.getTime()) { + return empty; + } + + // Check cache + const cached = blockTokenCache.get(filePath); + if (cached && cached.mtimeMs === stats.mtimeMs) { + return cached.metrics; + } + + const content = readFileSync(filePath, 'utf-8'); + const lines = content.trim().split('\n').filter(line => line.length > 0); + + let inputTokens = 0; + let outputTokens = 0; + let cacheCreationTokens = 0; + let cacheReadTokens = 0; + let readCostUsd = 0; + let writeCostUsd = 0; + + for (const line of lines) { + // Quick pre-filter for performance + if (!line.includes('"usage"')) { + continue; + } + + try { + const data = JSON.parse(line) as TranscriptLine; + const usage = data.message?.usage; + if (!usage) + continue; + if (data.isSidechain === true) + continue; + + // Filter by timestamp within block + if (data.timestamp) { + const entryTime = new Date(data.timestamp); + if (entryTime.getTime() < blockStart.getTime() || entryTime.getTime() > blockEnd.getTime()) { + continue; + } + } + + const inp = usage.input_tokens || 0; + const out = usage.output_tokens || 0; + const cc = usage.cache_creation_input_tokens ?? 0; + const cr = usage.cache_read_input_tokens ?? 0; + const modelId = data.message?.model; + + inputTokens += inp; + outputTokens += out; + cacheCreationTokens += cc; + cacheReadTokens += cr; + readCostUsd += estimateCostUsd(inp, 0, cc, cr, modelId); + writeCostUsd += estimateCostUsd(0, out, 0, 0, modelId); + } catch { + continue; + } + } + + const totalTokens = inputTokens + outputTokens + cacheCreationTokens + cacheReadTokens; + const estimatedCostUsd = readCostUsd + writeCostUsd; + + const metrics: BlockTokenMetrics = { + inputTokens, outputTokens, + cacheCreationTokens, cacheReadTokens, + totalTokens, readCostUsd, writeCostUsd, estimatedCostUsd, + estimatedMaxTokens: 0, estimatedMaxCostUsd: 0, isMaxEstimated: true + }; + + blockTokenCache.set(filePath, { mtimeMs: stats.mtimeMs, metrics }); + return metrics; + } catch { + return empty; + } +} + +interface EstimatedMax { + tokens: number; + costUsd: number; + isEstimated: boolean; +} + +/** + * Estimates the max tokens and cost per block by scanning historical blocks (last 14 days). + * Finds the block with the highest total token usage among completed blocks. + * Results are cached for 1 hour. + */ +function getEstimatedMax(claudeDir: string, currentBlockStart: Date): EstimatedMax { + const now = Date.now(); + if (maxCache && (now - maxCache.computedAt) < MAX_CACHE_TTL_MS) { + return { tokens: maxCache.tokens, costUsd: maxCache.costUsd, isEstimated: maxCache.isEstimated }; + } + + const sessionDurationMs = 5 * 60 * 60 * 1000; + const lookbackMs = 14 * 24 * 60 * 60 * 1000; // 14 days + const cutoffTime = new Date(now - lookbackMs); + + const pattern = path.posix.join(claudeDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); + const files = globSync([pattern], { absolute: true, cwd: claudeDir }); + + // Collect timestamps + token/cost data from files modified in lookback period + interface Entry { time: Date; tokens: number; costUsd: number } + const entries: Entry[] = []; + + for (const file of files) { + try { + const stats = statSync(file); + if (stats.mtime.getTime() < cutoffTime.getTime()) { + continue; + } + + const content = readFileSync(file, 'utf-8'); + const lines = content.trim().split('\n').filter(line => line.length > 0); + + for (const line of lines) { + if (!line.includes('"usage"')) { + continue; + } + + try { + const data = JSON.parse(line) as TranscriptLine; + const usage = data.message?.usage; + if (!usage) + continue; + if (data.isSidechain === true) + continue; + if (!data.timestamp) + continue; + + const entryTime = new Date(data.timestamp); + if (Number.isNaN(entryTime.getTime())) + continue; + + const inp = usage.input_tokens || 0; + const out = usage.output_tokens || 0; + const cc = usage.cache_creation_input_tokens ?? 0; + const cr = usage.cache_read_input_tokens ?? 0; + + const tokens = inp + out + cc + cr; + const costUsd = estimateCostUsd(inp, out, cc, cr, data.message?.model); + + entries.push({ time: entryTime, tokens, costUsd }); + } catch { + continue; + } + } + } catch { + continue; + } + } + + const fallback: EstimatedMax = { tokens: FALLBACK_MAX_TOKENS, costUsd: 0, isEstimated: true }; + + if (entries.length === 0) { + maxCache = { tokens: fallback.tokens, costUsd: fallback.costUsd, isEstimated: true, computedAt: now }; + return fallback; + } + + entries.sort((a, b) => a.time.getTime() - b.time.getTime()); + + // Group entries into 5-hour blocks, track the block with the highest token total + let currentStart: Date | null = null; + let currentEnd: Date | null = null; + let blockTokens = 0; + let blockCost = 0; + let maxBlockTokens = 0; + let maxBlockCost = 0; + + for (const entry of entries) { + if (!currentStart || !currentEnd || entry.time.getTime() > currentEnd.getTime()) { + // Save previous block (skip the current active block) + if (currentStart && currentStart.getTime() !== currentBlockStart.getTime()) { + if (blockTokens > maxBlockTokens) { + maxBlockTokens = blockTokens; + maxBlockCost = blockCost; + } + } + currentStart = floorToHour(entry.time); + currentEnd = new Date(currentStart.getTime() + sessionDurationMs); + blockTokens = 0; + blockCost = 0; + } + blockTokens += entry.tokens; + blockCost += entry.costUsd; + } + + // Don't count the current block in the max + if (currentStart && currentStart.getTime() !== currentBlockStart.getTime()) { + if (blockTokens > maxBlockTokens) { + maxBlockTokens = blockTokens; + maxBlockCost = blockCost; + } + } + + const isEstimated = maxBlockTokens === 0 || maxBlockTokens < MIN_MAX_TOKENS; + const resultTokens = maxBlockTokens > 0 ? Math.max(maxBlockTokens, MIN_MAX_TOKENS) : FALLBACK_MAX_TOKENS; + const resultCost = maxBlockCost; + maxCache = { tokens: resultTokens, costUsd: resultCost, isEstimated, computedAt: now }; + return { tokens: resultTokens, costUsd: resultCost, isEstimated }; +} + +/** + * Gets total token usage across all sessions in the current 5-hour block. + * Requires blockMetrics to determine the block timeframe. + */ +export function getBlockTokenMetrics(blockMetrics: BlockMetrics): BlockTokenMetrics { + const claudeDir = getClaudeConfigDir(); + if (!claudeDir) { + return { + inputTokens: 0, outputTokens: 0, + cacheCreationTokens: 0, cacheReadTokens: 0, + totalTokens: 0, readCostUsd: 0, writeCostUsd: 0, estimatedCostUsd: 0, + estimatedMaxTokens: FALLBACK_MAX_TOKENS, estimatedMaxCostUsd: 0, + isMaxEstimated: true + }; + } + + const blockStart = blockMetrics.startTime; + const blockEnd = new Date(blockStart.getTime() + 5 * 60 * 60 * 1000); + + // Find all JSONL files + const pattern = path.posix.join(claudeDir.replace(/\\/g, '/'), 'projects', '**', '*.jsonl'); + const files = globSync([pattern], { absolute: true, cwd: claudeDir }); + + let totalInput = 0; + let totalOutput = 0; + let totalCacheCreation = 0; + let totalCacheRead = 0; + let totalReadCost = 0; + let totalWriteCost = 0; + + for (const file of files) { + const metrics = getFileBlockTokens(file, blockStart, blockEnd); + totalInput += metrics.inputTokens; + totalOutput += metrics.outputTokens; + totalCacheCreation += metrics.cacheCreationTokens; + totalCacheRead += metrics.cacheReadTokens; + totalReadCost += metrics.readCostUsd; + totalWriteCost += metrics.writeCostUsd; + } + + const totalTokens = totalInput + totalOutput + totalCacheCreation + totalCacheRead; + const estimatedMax = getEstimatedMax(claudeDir, blockStart); + + return { + inputTokens: totalInput, + outputTokens: totalOutput, + cacheCreationTokens: totalCacheCreation, + cacheReadTokens: totalCacheRead, + totalTokens, + readCostUsd: totalReadCost, + writeCostUsd: totalWriteCost, + estimatedCostUsd: totalReadCost + totalWriteCost, + estimatedMaxTokens: estimatedMax.tokens, + estimatedMaxCostUsd: estimatedMax.costUsd, + isMaxEstimated: estimatedMax.isEstimated + }; +} + /** * Floors a timestamp to the beginning of the hour (matching existing logic) */ diff --git a/src/utils/pricing.ts b/src/utils/pricing.ts new file mode 100644 index 0000000..b38a07d --- /dev/null +++ b/src/utils/pricing.ts @@ -0,0 +1,75 @@ +/** + * Model-specific token pricing (USD per 1M tokens). + * Based on Anthropic's published pricing. + * https://docs.anthropic.com/en/docs/about-claude/models + */ + +export interface ModelPricing { + inputPerMillion: number; + outputPerMillion: number; + cacheWritePerMillion: number; + cacheReadPerMillion: number; +} + +/** + * Model family prefix → pricing. Ordered longest-prefix-first so matching + * can short-circuit on the first hit when iterating. + */ +export const MODEL_PRICING: Record = { + 'claude-opus-4-6': { inputPerMillion: 5, outputPerMillion: 25, cacheWritePerMillion: 6.25, cacheReadPerMillion: 0.50 }, + 'claude-opus-4-5': { inputPerMillion: 5, outputPerMillion: 25, cacheWritePerMillion: 6.25, cacheReadPerMillion: 0.50 }, + 'claude-opus-4-1': { inputPerMillion: 15, outputPerMillion: 75, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.50 }, + 'claude-opus-4-': { inputPerMillion: 15, outputPerMillion: 75, cacheWritePerMillion: 18.75, cacheReadPerMillion: 1.50 }, + 'claude-sonnet-4-6': { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30 }, + 'claude-sonnet-4-5': { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30 }, + 'claude-sonnet-4-': { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30 }, + 'claude-sonnet-3-7': { inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.30 }, + 'claude-haiku-4-5': { inputPerMillion: 1, outputPerMillion: 5, cacheWritePerMillion: 1.25, cacheReadPerMillion: 0.10 }, + 'claude-haiku-3-5': { inputPerMillion: 0.80, outputPerMillion: 4, cacheWritePerMillion: 1.00, cacheReadPerMillion: 0.08 }, + 'claude-haiku-3': { inputPerMillion: 0.25, outputPerMillion: 1.25, cacheWritePerMillion: 0.30, cacheReadPerMillion: 0.03 } +}; + +/** Sorted prefixes longest-first for matching. */ +const SORTED_PREFIXES = Object.keys(MODEL_PRICING).sort((a, b) => b.length - a.length); + +/** Default fallback when model is unknown (Sonnet 4.6 — most common in Claude Code). */ +export const DEFAULT_PRICING: ModelPricing = { + inputPerMillion: 3, + outputPerMillion: 15, + cacheWritePerMillion: 3.75, + cacheReadPerMillion: 0.30 +}; + +/** + * Resolve pricing for a model ID by longest prefix match. + */ +export function getPricingForModel(modelId?: string): ModelPricing { + if (!modelId) + return DEFAULT_PRICING; + for (const prefix of SORTED_PREFIXES) { + const pricing = MODEL_PRICING[prefix]; + if (modelId.startsWith(prefix) && pricing) { + return pricing; + } + } + return DEFAULT_PRICING; +} + +/** + * Estimate USD cost from token counts, optionally using model-specific pricing. + */ +export function estimateCostUsd( + inputTokens: number, + outputTokens: number, + cacheCreationTokens: number, + cacheReadTokens: number, + modelId?: string +): number { + const pricing = getPricingForModel(modelId); + return ( + inputTokens * pricing.inputPerMillion + + outputTokens * pricing.outputPerMillion + + cacheCreationTokens * pricing.cacheWritePerMillion + + cacheReadTokens * pricing.cacheReadPerMillion + ) / 1_000_000; +} \ No newline at end of file diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 15a2510..dc7581f 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -23,11 +23,16 @@ const widgetRegistry = new Map([ ['session-clock', new widgets.SessionClockWidget()], ['session-cost', new widgets.SessionCostWidget()], ['block-timer', new widgets.BlockTimerWidget()], + ['block-usage', new widgets.BlockUsageWidget()], ['terminal-width', new widgets.TerminalWidthWidget()], ['version', new widgets.VersionWidget()], ['custom-text', new widgets.CustomTextWidget()], ['custom-command', new widgets.CustomCommandWidget()], - ['claude-session-id', new widgets.ClaudeSessionIdWidget()] + ['claude-session-id', new widgets.ClaudeSessionIdWidget()], + ['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/ApiUsage.tsx b/src/widgets/ApiUsage.tsx new file mode 100644 index 0000000..c77f046 --- /dev/null +++ b/src/widgets/ApiUsage.tsx @@ -0,0 +1,515 @@ +import { + execSync, + spawnSync +} from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +// Cache configuration +const CACHE_FILE = path.join(process.env.HOME ?? '', '.cache', 'ccstatusline-api.json'); +const LOCK_FILE = path.join(process.env.HOME ?? '', '.cache', 'ccstatusline-api.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 + +// Error types matching shell script +type ApiError = 'no-credentials' | 'timeout' | 'api-error' | 'parse-error'; + +interface ApiData { + sessionUsage?: number; // five_hour.utilization (percentage) + sessionResetAt?: string; // five_hour.reset_at + weeklyUsage?: number; // seven_day.utilization (percentage) + weeklyResetAt?: string; // seven_day.resets_at + extraUsageEnabled?: boolean; + extraUsageLimit?: number; // in cents + extraUsageUsed?: number; // in cents + extraUsageUtilization?: number; + error?: ApiError; +} + +interface CredentialData { claudeAiOauth?: { accessToken?: string } } + +interface UsageApiResponse { + five_hour?: { + utilization: number; + resets_at: string; + }; + seven_day?: { + utilization: number; + resets_at: string; + }; + extra_usage?: { + is_enabled: boolean; + monthly_limit: number; + used_credits: number; + utilization: number; + }; +} + +// Memory caches +let cachedData: ApiData | null = null; +let cacheTime = 0; +let cachedToken: string | null = null; +let tokenCacheTime = 0; + +function getToken(): string | null { + const now = Math.floor(Date.now() / 1000); + + // Return cached token if still valid + if (cachedToken && (now - tokenCacheTime) < TOKEN_CACHE_MAX_AGE) { + return cachedToken; + } + + 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 parsed = JSON.parse(result) as CredentialData; + const token = parsed.claudeAiOauth?.accessToken ?? null; + if (token) { + cachedToken = token; + tokenCacheTime = now; + } + return token; + } else { + // Linux: read from credentials file + const credFile = path.join(process.env.HOME ?? '', '.claude', '.credentials.json'); + const creds = JSON.parse(fs.readFileSync(credFile, 'utf8')) as CredentialData; + const token = creds.claudeAiOauth?.accessToken ?? null; + if (token) { + cachedToken = token; + tokenCacheTime = now; + } + return token; + } + } catch { + return null; + } +} + +function readStaleCache(): ApiData | null { + try { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as ApiData; + } catch { + return null; + } +} + +// Fetch API using Node's built-in https module (no curl dependency) +function fetchFromApi(token: string): string | null { + // Use Node to make HTTPS request synchronously via spawnSync + const script = ` + const https = require('https'); + const options = { + hostname: 'api.anthropic.com', + path: '/api/oauth/usage', + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + process.env.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) { + 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 result = spawnSync('node', ['-e', script], { + encoding: 'utf8', + timeout: 6000, + env: { ...process.env, TOKEN: token } + }); + + if (result.error || result.status !== 0 || !result.stdout) { + return null; + } + + return result.stdout; +} + +function fetchApiData(): ApiData { + const now = Math.floor(Date.now() / 1000); + + // Check memory cache (fast path) + if (cachedData && !cachedData.error && (now - cacheTime) < CACHE_MAX_AGE) { + return cachedData; + } + + // 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 = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as ApiData; + if (!fileData.error) { + cachedData = fileData; + cacheTime = now; + return fileData; + } + } + } catch { + // File doesn't exist or read error - continue to API call + } + + // 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 = readStaleCache(); + 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 + } + + // Get token + const token = getToken(); + if (!token) { + const stale = readStaleCache(); + if (stale && !stale.error) + return stale; + return { error: 'no-credentials' }; + } + + // Fetch from API using Node's https module + try { + const response = fetchFromApi(token); + + if (!response) { + const stale = readStaleCache(); + if (stale && !stale.error) + return stale; + return { error: 'api-error' }; + } + + const data = JSON.parse(response) as UsageApiResponse; + + // Extract utilization data + const apiData: ApiData = {}; + if (data.five_hour) { + apiData.sessionUsage = data.five_hour.utilization; + apiData.sessionResetAt = data.five_hour.resets_at; + } + if (data.seven_day) { + apiData.weeklyUsage = data.seven_day.utilization; + apiData.weeklyResetAt = data.seven_day.resets_at; + } + if (data.extra_usage) { + apiData.extraUsageEnabled = data.extra_usage.is_enabled; + apiData.extraUsageLimit = data.extra_usage.monthly_limit; + apiData.extraUsageUsed = data.extra_usage.used_credits; + apiData.extraUsageUtilization = data.extra_usage.utilization; + } + + // Validate we got actual data + if (apiData.sessionUsage === undefined && apiData.weeklyUsage === undefined) { + const stale = readStaleCache(); + if (stale && !stale.error) + return stale; + return { error: 'parse-error' }; + } + + // Save to cache + try { + const cacheDir = path.dirname(CACHE_FILE); + if (!fs.existsSync(cacheDir)) { + fs.mkdirSync(cacheDir, { recursive: true }); + } + fs.writeFileSync(CACHE_FILE, JSON.stringify(apiData)); + } catch { + // Ignore cache write errors + } + + cachedData = apiData; + cacheTime = now; + return apiData; + } catch { + const stale = readStaleCache(); + if (stale && !stale.error) + return stale; + return { error: 'parse-error' }; + } +} + +function getErrorMessage(error: ApiError): 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]'; + } +} + +function formatResetCountdown(resetAt: string): string | null { + try { + const diffMs = new Date(resetAt).getTime() - Date.now(); + if (diffMs <= 0) + return '0hr 0m'; + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + return `${hours}hr ${minutes}m`; + } catch { + return null; + } +} + +function formatResetDay(resetAt: string): string | null { + try { + const resetDate = new Date(resetAt); + if (isNaN(resetDate.getTime())) + return null; + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const resetDayStart = new Date(resetDate.getFullYear(), resetDate.getMonth(), resetDate.getDate()); + const daysDiff = Math.round((resetDayStart.getTime() - todayStart.getTime()) / (1000 * 60 * 60 * 24)); + if (daysDiff <= 0) + return 'today'; + if (daysDiff === 1) + return 'tomorrow'; + return resetDate.toLocaleDateString('en-US', { weekday: 'long' }); + } catch { + return null; + } +} + +function makeProgressBar(percent: number, width = 15): string { + const filled = Math.round((percent / 100) * width); + const empty = width - filled; + return '[' + '█'.repeat(filled) + '░'.repeat(empty) + ']'; +} + +// Session Usage Widget +export class SessionUsageWidget implements Widget { + getDefaultColor(): string { return 'brightBlue'; } + getDescription(): string { return 'Shows daily/session API usage percentage'; } + getDisplayName(): string { return 'Session Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) + return item.rawValue ? 'Block: 20.0% (~$14/$89) resets in 3hr 45m' : 'Session: [███░░░░░░░░░░░░] 20%'; + + const data = fetchApiData(); + if (data.error) + return getErrorMessage(data.error); + if (data.sessionUsage === undefined) + return null; + + const percent = data.sessionUsage; + + if (item.rawValue) { + let result = `Block: ${percent.toFixed(1)}%`; + const metrics = context.blockTokenMetrics; + if (metrics) { + const current = `~$${Math.round(metrics.estimatedCostUsd)}`; + const max = metrics.estimatedMaxCostUsd > 0 + ? `$${Math.round(metrics.estimatedMaxCostUsd)}` + : '?'; + result += ` (${current}/${max})`; + } + if (data.sessionResetAt) { + const countdown = formatResetCountdown(data.sessionResetAt); + if (countdown) + result += ` resets in ${countdown}`; + } + return result; + } + + return `Session: ${makeProgressBar(percent)} ${percent.toFixed(1)}%`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} + +// Weekly Usage Widget +export class WeeklyUsageWidget implements Widget { + getDefaultColor(): string { return 'brightBlue'; } + getDescription(): string { return 'Shows weekly API usage percentage'; } + getDisplayName(): string { return 'Weekly Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) + return item.rawValue ? 'Weekly: 12.0% resets Friday' : 'Weekly: [██░░░░░░░░░░░░░] 12%'; + + const data = fetchApiData(); + if (data.error) + return getErrorMessage(data.error); + if (data.weeklyUsage === undefined) + return null; + + const percent = data.weeklyUsage; + + if (item.rawValue) { + let result = `Weekly: ${percent.toFixed(1)}%`; + if (data.weeklyResetAt) { + const day = formatResetDay(data.weeklyResetAt); + if (day) + result += ` resets ${day}`; + } + return result; + } + + return `Weekly: ${makeProgressBar(percent)} ${percent.toFixed(1)}%`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} + +// Reset Timer Widget — shows extra usage spending when weekly limit is reached, otherwise reset timer +export class ResetTimerWidget implements Widget { + getDefaultColor(): string { return 'brightBlue'; } + getDescription(): string { return 'Shows extra usage spending or time until limit reset'; } + getDisplayName(): string { return 'Reset Timer'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) + return '4:30 hr'; + + const data = fetchApiData(); + if (data.error) + return getErrorMessage(data.error); + + // When extra usage is active, show spending instead of reset timer + if (data.extraUsageEnabled && data.extraUsageUsed !== undefined && data.extraUsageLimit !== undefined) { + const used = formatCents(data.extraUsageUsed); + const limit = formatCents(data.extraUsageLimit); + return `Extra: ${used}/${limit}`; + } + + if (!data.sessionResetAt) + return null; + + try { + const resetTime = new Date(data.sessionResetAt).getTime(); + const now = Date.now(); + const diffMs = resetTime - now; + + if (diffMs <= 0) + return '0:00 hr'; + + const hours = Math.floor(diffMs / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + return `${hours}:${minutes.toString().padStart(2, '0')} hr`; + } catch { + return null; + } + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} + +function getCurrencySymbol(): string { + try { + const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; + if (tz.startsWith('Europe/')) + return '€'; + } catch { + // Fall through to default + } + return '$'; +} + +function formatCents(cents: number): string { + const symbol = getCurrencySymbol(); + return `${symbol}${(cents / 100).toFixed(2)}`; +} + +// Context Bar Widget (enhanced context display) +export class ContextBarWidget implements Widget { + getDefaultColor(): string { return 'blue'; } + getDescription(): string { return 'Shows context usage as a progress bar'; } + getDisplayName(): string { return 'Context Bar'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) + return 'Context: [████░░░░░░░░░░░] 50k/200k (25%)'; + + const cw = context.data?.context_window; + if (!cw) + return null; + + const total = Number(cw.context_window_size) || 200000; + + // current_usage can be a number or an object with token breakdown + let used = 0; + if (typeof cw.current_usage === 'number') { + used = cw.current_usage; + } else if (cw.current_usage && typeof cw.current_usage === 'object') { + const u = cw.current_usage; + used = (Number(u.input_tokens) || 0) + + (Number(u.output_tokens) || 0) + + (Number(u.cache_creation_input_tokens) || 0) + + (Number(u.cache_read_input_tokens) || 0); + } + + if (isNaN(total) || isNaN(used)) + return null; + + const percent = total > 0 ? (used / total) * 100 : 0; + + const usedK = Math.round(used / 1000); + const totalK = Math.round(total / 1000); + + return `Context: ${makeProgressBar(percent)} ${usedK}k/${totalK}k (${Math.round(percent)}%)`; + } + + supportsRawValue(): boolean { return false; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/BlockUsage.ts b/src/widgets/BlockUsage.ts new file mode 100644 index 0000000..e204d28 --- /dev/null +++ b/src/widgets/BlockUsage.ts @@ -0,0 +1,40 @@ +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +export class BlockUsageWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows current block cost vs estimated max cost'; } + getDisplayName(): string { return 'Block Usage'; } + + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '$14.50/~$89.20' : 'Block: $14.50/~$89.20'; + } + + const metrics = context.blockTokenMetrics; + if (!metrics) { + return null; + } + + const current = `$${metrics.estimatedCostUsd.toFixed(2)}`; + const max = metrics.estimatedMaxCostUsd > 0 + ? `~$${metrics.estimatedMaxCostUsd.toFixed(2)}` + : '~?'; + const suffix = metrics.isMaxEstimated ? ' (insufficient data)' : ''; + const value = `${current}/${max}${suffix}`; + + return item.rawValue ? value : `Block: ${value}`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 7111dbb..3a7837d 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -53,6 +53,21 @@ export class ContextPercentageWidget implements Widget { 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)}%`; + } else if (context.data?.context_window) { + const ctxWindow = context.data.context_window; + let usedPercentage: number | null = null; + + // Use pre-calculated percentage if available (Claude Code v2.1.19+) + if (typeof ctxWindow.used_percentage === 'number') { + usedPercentage = ctxWindow.used_percentage; + } else if (typeof ctxWindow.current_usage === 'number' && ctxWindow.context_window_size) { + usedPercentage = Math.min(100, (ctxWindow.current_usage / ctxWindow.context_window_size) * 100); + } + + if (usedPercentage !== null) { + const displayPercentage = isInverse ? (100 - usedPercentage) : usedPercentage; + return item.rawValue ? `${displayPercentage.toFixed(1)}%` : `Ctx: ${displayPercentage.toFixed(1)}%`; + } } return null; } diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 79a43d8..ade4aa4 100644 --- a/src/widgets/__tests__/GitWorktree.test.ts +++ b/src/widgets/__tests__/GitWorktree.test.ts @@ -1,22 +1,22 @@ +import { execSync } from 'child_process'; import { beforeEach, describe, expect, it, - vi + vi, + type Mock } from 'vitest'; -vi.mock('child_process', () => ({ execSync: vi.fn() })); - -import { execSync } from 'child_process'; - import type { RenderContext, WidgetItem } from '../../types'; import { GitWorktreeWidget } from '../GitWorktree'; -const mockExecSync = vi.mocked(execSync); +vi.mock('child_process', () => ({ execSync: vi.fn() })); + +const mockExecSync = execSync as Mock; function render(rawValue = false, isPreview = false) { const widget = new GitWorktreeWidget(); @@ -78,4 +78,4 @@ describe('GitWorktreeWidget', () => { expect(render()).toBe('𖠰 no git'); }); -}); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index faaa705..a61b050 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -17,5 +17,7 @@ export { VersionWidget } from './Version'; export { CustomTextWidget } from './CustomText'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; +export { BlockUsageWidget } from './BlockUsage'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; -export { ClaudeSessionIdWidget } from './ClaudeSessionId'; \ No newline at end of file +export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { ContextBarWidget, ResetTimerWidget, SessionUsageWidget, WeeklyUsageWidget } from './ApiUsage'; \ No newline at end of file