From e643ee643956a702415efc06cff558c43d4b4232 Mon Sep 17 00:00:00 2001 From: Peter van Velzen Date: Thu, 12 Feb 2026 00:39:40 +0100 Subject: [PATCH 1/4] feat: add API usage widgets (session, weekly, reset timer, context bar) Add four new widgets that display Claude Code API usage data: - session-usage: 5-hour session utilization with progress bar - weekly-usage: 7-day utilization with progress bar - reset-timer: countdown to session reset - context-bar: context window usage with progress bar Fetches data from /api/oauth/usage using existing OAuth credentials (macOS Keychain + Linux file fallback). Includes 180s cache with 30s rate limiting to minimize API calls. Also enhances ContextPercentage widget to use context_window data from Claude Code's stdin JSON when available. Closes #94 --- src/types/StatusJSON.ts | 18 +- src/utils/widgets.ts | 6 +- src/widgets/ApiUsage.tsx | 432 +++++++++++++++++++++++++++++++ src/widgets/ContextPercentage.ts | 17 ++ src/widgets/index.ts | 3 +- 5 files changed, 473 insertions(+), 3 deletions(-) create mode 100644 src/widgets/ApiUsage.tsx diff --git a/src/types/StatusJSON.ts b/src/types/StatusJSON.ts index 767b8365..fd5012ae 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/widgets.ts b/src/utils/widgets.ts index 15a25103..2897a2ad 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -27,7 +27,11 @@ const widgetRegistry = new Map([ ['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 00000000..9bc382b1 --- /dev/null +++ b/src/widgets/ApiUsage.tsx @@ -0,0 +1,432 @@ +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) + extraUsageEnabled?: boolean; + extraUsageLimit?: number; // in cents + extraUsageUsed?: number; // in cents + extraUsageUtilization?: number; + error?: ApiError; +} + +// 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); + 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')); + 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')); + } 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: ApiData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + 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); + + // 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; + } + if (data.extra_usage) { + apiData.extraUsageEnabled = data.extra_usage.is_enabled === true; + 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 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 'Session: [███░░░░░░░░░░░░] 20%'; + + const data = fetchApiData(); + if (data.error) + return getErrorMessage(data.error); + if (data.sessionUsage === undefined) + return null; + + const percent = data.sessionUsage; + return `Session: ${makeProgressBar(percent)} ${percent.toFixed(1)}%`; + } + + supportsRawValue(): boolean { return false; } + 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 'Weekly: [██░░░░░░░░░░░░░] 12%'; + + const data = fetchApiData(); + if (data.error) + return getErrorMessage(data.error); + if (data.weeklyUsage === undefined) + return null; + + const percent = data.weeklyUsage; + return `Weekly: ${makeProgressBar(percent)} ${percent.toFixed(1)}%`; + } + + supportsRawValue(): boolean { return false; } + 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/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index 7111dbb7..d84ba7de 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -53,6 +53,23 @@ 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; + } + // Fall back to calculating from current_usage if it's a number + 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/index.ts b/src/widgets/index.ts index faaa705e..fcbf30c2 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; 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 From 20aa9a055ac7c5d458c83a030de5b38a2196e487 Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Thu, 19 Feb 2026 08:57:37 +0000 Subject: [PATCH 2/4] feat: add block-usage widget showing current vs max block cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `block-usage` widget that shows the estimated cost of the current 5-hour block compared to the highest historical block cost. Display format: `Block: $14.50/~$89.20` The widget scans all transcript JSONL files within the current block timeframe, sums token usage (input, output, cache creation, cache read) across all sessions, and computes cost using shared pricing constants. The estimated max is auto-learned by scanning the last 14 days of transcript history, grouping entries into 5-hour blocks, and finding the block with the highest cost. Results are cached for 1 hour. Token pricing is centralized in src/utils/pricing.ts for easy updates. New files: - src/widgets/BlockUsage.ts — widget implementation - src/utils/pricing.ts — shared token pricing constants and cost estimation - src/types/TokenMetrics.ts — BlockTokenMetrics interface - src/utils/jsonl.ts — getBlockTokenMetrics(), getEstimatedMax() --- src/ccstatusline.ts | 13 +- src/types/RenderContext.ts | 6 +- src/types/TokenMetrics.ts | 16 ++- src/types/index.ts | 2 +- src/utils/jsonl.ts | 286 +++++++++++++++++++++++++++++++++++++ src/utils/pricing.ts | 74 ++++++++++ src/utils/widgets.ts | 1 + src/widgets/BlockUsage.ts | 40 ++++++ src/widgets/index.ts | 1 + 9 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 src/utils/pricing.ts create mode 100644 src/widgets/BlockUsage.ts diff --git a/src/ccstatusline.ts b/src/ccstatusline.ts index 0bc999dc..a1b79179 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 9e088c9b..ac553cc1 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/TokenMetrics.ts b/src/types/TokenMetrics.ts index ec3c8664..fbee9fec 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 916ded08..12c43b2d 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 d00923c5..cb76bd5c 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 || 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 00000000..777aa42a --- /dev/null +++ b/src/utils/pricing.ts @@ -0,0 +1,74 @@ +/** + * 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; +} diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 15a25103..00510a4a 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -23,6 +23,7 @@ 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()], diff --git a/src/widgets/BlockUsage.ts b/src/widgets/BlockUsage.ts new file mode 100644 index 00000000..e204d286 --- /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/index.ts b/src/widgets/index.ts index faaa705e..1f9e9073 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -17,5 +17,6 @@ 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 From e2eea314a6b32428286b9b4370be5b40ed8b0e82 Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Thu, 19 Feb 2026 11:25:45 +0000 Subject: [PATCH 3/4] feat: add raw value mode to session/weekly usage widgets with reset timers Session usage raw mode shows block percentage, estimated cost vs max, and countdown to reset. Weekly usage raw mode shows percentage and reset day. Also fixes lint errors across branch (type safety, brace style, test imports). Co-Authored-By: Claude Opus 4.6 --- src/utils/jsonl.ts | 2 +- src/utils/pricing.ts | 7 +- src/widgets/ApiUsage.tsx | 107 +++++++++++++++++++--- src/widgets/ContextPercentage.ts | 4 +- src/widgets/__tests__/GitWorktree.test.ts | 14 +-- 5 files changed, 108 insertions(+), 26 deletions(-) diff --git a/src/utils/jsonl.ts b/src/utils/jsonl.ts index cb76bd5c..335bd10c 100644 --- a/src/utils/jsonl.ts +++ b/src/utils/jsonl.ts @@ -556,7 +556,7 @@ function getEstimatedMax(claudeDir: string, currentBlockStart: Date): EstimatedM let maxBlockCost = 0; for (const entry of entries) { - if (!currentStart || entry.time.getTime() > currentEnd!.getTime()) { + 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) { diff --git a/src/utils/pricing.ts b/src/utils/pricing.ts index 777aa42a..b38a07d1 100644 --- a/src/utils/pricing.ts +++ b/src/utils/pricing.ts @@ -26,7 +26,7 @@ export const MODEL_PRICING: Record = { '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 }, + 'claude-haiku-3': { inputPerMillion: 0.25, outputPerMillion: 1.25, cacheWritePerMillion: 0.30, cacheReadPerMillion: 0.03 } }; /** Sorted prefixes longest-first for matching. */ @@ -44,7 +44,8 @@ export const DEFAULT_PRICING: ModelPricing = { * Resolve pricing for a model ID by longest prefix match. */ export function getPricingForModel(modelId?: string): ModelPricing { - if (!modelId) return DEFAULT_PRICING; + if (!modelId) + return DEFAULT_PRICING; for (const prefix of SORTED_PREFIXES) { const pricing = MODEL_PRICING[prefix]; if (modelId.startsWith(prefix) && pricing) { @@ -71,4 +72,4 @@ export function estimateCostUsd( + cacheCreationTokens * pricing.cacheWritePerMillion + cacheReadTokens * pricing.cacheReadPerMillion ) / 1_000_000; -} +} \ No newline at end of file diff --git a/src/widgets/ApiUsage.tsx b/src/widgets/ApiUsage.tsx index 9bc382b1..c77f046f 100644 --- a/src/widgets/ApiUsage.tsx +++ b/src/widgets/ApiUsage.tsx @@ -27,6 +27,7 @@ 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 @@ -34,6 +35,25 @@ interface ApiData { 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; @@ -56,8 +76,8 @@ function getToken(): string | null { 'security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] } ).trim(); - const parsed = JSON.parse(result); - const token = parsed?.claudeAiOauth?.accessToken ?? null; + const parsed = JSON.parse(result) as CredentialData; + const token = parsed.claudeAiOauth?.accessToken ?? null; if (token) { cachedToken = token; tokenCacheTime = now; @@ -66,8 +86,8 @@ function getToken(): string | null { } else { // Linux: read from credentials file const credFile = path.join(process.env.HOME ?? '', '.claude', '.credentials.json'); - const creds = JSON.parse(fs.readFileSync(credFile, 'utf8')); - const token = creds?.claudeAiOauth?.accessToken ?? null; + const creds = JSON.parse(fs.readFileSync(credFile, 'utf8')) as CredentialData; + const token = creds.claudeAiOauth?.accessToken ?? null; if (token) { cachedToken = token; tokenCacheTime = now; @@ -81,7 +101,7 @@ function getToken(): string | null { function readStaleCache(): ApiData | null { try { - return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as ApiData; } catch { return null; } @@ -144,7 +164,7 @@ function fetchApiData(): ApiData { const stat = fs.statSync(CACHE_FILE); const fileAge = now - Math.floor(stat.mtimeMs / 1000); if (fileAge < CACHE_MAX_AGE) { - const fileData: ApiData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); + const fileData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')) as ApiData; if (!fileData.error) { cachedData = fileData; cacheTime = now; @@ -201,7 +221,7 @@ function fetchApiData(): ApiData { return { error: 'api-error' }; } - const data = JSON.parse(response); + const data = JSON.parse(response) as UsageApiResponse; // Extract utilization data const apiData: ApiData = {}; @@ -211,9 +231,10 @@ function fetchApiData(): ApiData { } 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 === true; + 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; @@ -258,6 +279,38 @@ function getErrorMessage(error: ApiError): string { } } +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; @@ -276,7 +329,7 @@ export class SessionUsageWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) - return 'Session: [███░░░░░░░░░░░░] 20%'; + return item.rawValue ? 'Block: 20.0% (~$14/$89) resets in 3hr 45m' : 'Session: [███░░░░░░░░░░░░] 20%'; const data = fetchApiData(); if (data.error) @@ -285,10 +338,29 @@ export class SessionUsageWidget implements Widget { 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 false; } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } @@ -304,7 +376,7 @@ export class WeeklyUsageWidget implements Widget { render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { if (context.isPreview) - return 'Weekly: [██░░░░░░░░░░░░░] 12%'; + return item.rawValue ? 'Weekly: 12.0% resets Friday' : 'Weekly: [██░░░░░░░░░░░░░] 12%'; const data = fetchApiData(); if (data.error) @@ -313,10 +385,21 @@ export class WeeklyUsageWidget implements Widget { 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 false; } + supportsRawValue(): boolean { return true; } supportsColors(item: WidgetItem): boolean { return true; } } diff --git a/src/widgets/ContextPercentage.ts b/src/widgets/ContextPercentage.ts index d84ba7de..3a7837db 100644 --- a/src/widgets/ContextPercentage.ts +++ b/src/widgets/ContextPercentage.ts @@ -60,9 +60,7 @@ export class ContextPercentageWidget implements Widget { // Use pre-calculated percentage if available (Claude Code v2.1.19+) if (typeof ctxWindow.used_percentage === 'number') { usedPercentage = ctxWindow.used_percentage; - } - // Fall back to calculating from current_usage if it's a number - else if (typeof ctxWindow.current_usage === 'number' && ctxWindow.context_window_size) { + } else if (typeof ctxWindow.current_usage === 'number' && ctxWindow.context_window_size) { usedPercentage = Math.min(100, (ctxWindow.current_usage / ctxWindow.context_window_size) * 100); } diff --git a/src/widgets/__tests__/GitWorktree.test.ts b/src/widgets/__tests__/GitWorktree.test.ts index 79a43d8e..ade4aa43 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 From 9583452f07c788d8bf97bf71a4d9b2221c397d7d Mon Sep 17 00:00:00 2001 From: Robert Parker Date: Thu, 19 Feb 2026 11:25:59 +0000 Subject: [PATCH 4/4] docs: add Rob's setup guide for sharing status line configuration Co-Authored-By: Claude Opus 4.6 --- ROB-SETUP-GUIDE.md | 123 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 ROB-SETUP-GUIDE.md diff --git a/ROB-SETUP-GUIDE.md b/ROB-SETUP-GUIDE.md new file mode 100644 index 00000000..1f0c94ab --- /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.)