Weather & Bitcoin Bar
node
A three-line bar that mixes coding metrics with ambient real-world data: model, context and git on top; date, time, live weather from wttr.in and the Bitcoin price from Coinbase in the middle; a context bar with real token counts, session cost and Claude Code version below.
MIT licensed · original source
Updated 2026-07-07
Preview
Clean repo
🤖 Opus 4.8 [200K] │ 🎯 high │ 📁 app │ 🌿 main
📅 07/07/26 Tue │ ⏰ 23:06:08 │ ₿$64k │ ☀️ +90°F
Ctx: ● ▓▓░░░░ 33% [66K/200K] │ ⏱ 10m │ 🔧 2.1.155
New session
🤖 Opus 4.8 [200K] │ 🎯 high │ 📁 app │ 🌿 main
📅 07/07/26 Tue │ ⏰ 23:06:10 │ ₿$64k │ ☀️ +90°F
Ctx: ~ ▓░░░░░ 11% [22K/200K] │ ⏱ 0m │ 🔧 2.1.155
Dirty branch
🤖 Sonnet 4.6 [1M] │ 🎯 high │ 📁 app │ 🌿 feat/auth │ ✏️ │ 📦 1
📅 07/07/26 Tue │ ⏰ 23:06:13 │ ₿$64k │ ☀️ +90°F
Ctx: ● ▓░░░░░ 14% [141K/1M] │ ⏱ 10m │ 🔧 2.1.155
Near-full
🤖 Opus 4.8 [200K] │ 🎯 high │ 📁 app │ 🌿 main
📅 07/07/26 Tue │ ⏰ 23:06:16 │ ₿$64k │ ☀️ +90°F
Ctx: ● ▓▓▓▓▓▓ 132% [265K/200K] │ ⏱ 10m │ 🔧 2.1.155
1M context
🤖 Fable 5 [200K] │ 🎯 high │ 📁 app │ 🌿 main
📅 07/07/26 Tue │ ⏰ 23:06:18 │ ₿$64k │ ☀️ +90°F
Ctx: ● ▓▓▓▓▓▓ 462% [924K/200K] │ ⏱ 10m │ 🔧 2.1.155
Post-compact
🤖 Haiku 4.5 [200K] │ 🎯 high │ 📁 app │ 🌿 main
📅 07/07/26 Tue │ ⏰ 23:06:21 │ ₿$64k │ ☀️ +90°F
Ctx: ● ░░░░░░ 1% [3K/200K] │ ⏱ 10m │ 🔧 2.1.155
Worktree
🤖 Opus 4.8 [200K] │ 🎯 high │ 📁 feature │ 🌿 worktree-feature
📅 07/07/26 Tue │ ⏰ 23:06:23 │ ₿$64k │ ☀️ +90°F
Ctx: ● ▓▓▓░░░ 55% [109K/200K] │ ⏱ 10m │ 🔧 2.1.155
Non-git
🤖 Opus 4.8 [200K] │ 🎯 high │ 📁 scratch
📅 07/07/26 Tue │ ⏰ 23:06:25 │ ₿$64k │ ☀️ +90°F
Ctx: ● ▓▓░░░░ 33% [66K/200K] │ ⏱ 10m │ 🔧 2.1.155
Source
#!/usr/bin/env node
/**
* Enhanced Claude Code Status Line - Real Token Tracking Edition
* Professional status line with weather, git info, Bitcoin price, Claude Code version, and REAL token usage
*
* Features:
* - Real token usage from Claude Code transcript files (input_tokens + cache_creation_input_tokens + cache_read_input_tokens)
* - Weather integration with 30-minute caching
* - Bitcoin price tracking with 15-minute caching
* - Git branch and status display
* - Context window progress bar with actual token counts
* - Visual indicator: ● (bright green) = real data, ~ (dimmed yellow) = estimated data
* - Updates dynamically after each message with accurate percentages
*
* Configure your zip code/location in the weather section below
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const https = require('https');
const os = require('os');
// bplist-parser no longer needed - using direct API fetch for all usage data
// ANSI color codes for terminal styling
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m',
orange: '\x1b[38;5;208m',
bgRed: '\x1b[41m',
bgGreen: '\x1b[42m',
bgYellow: '\x1b[43m',
bgBlue: '\x1b[44m',
bgMagenta: '\x1b[45m',
bgCyan: '\x1b[46m'
};
// ========================================
// CONFIGURATION SECTION - CUSTOMIZE HERE
// ========================================
// Weather Configuration - Change to your location!
const WEATHER_CONFIG = {
zipCode: '90001', // Your zip code (e.g., '90001' for Los Angeles)
cityName: 'Los Angeles,CA', // Your city and state (e.g., 'New York,NY')
latitude: 34.0522, // Your latitude (optional, for fallback)
longitude: -118.2437, // Your longitude (optional, for fallback)
defaultLocation: 'Los Angeles',// Display name when location unknown
celsius: false // Set true for °C, false for °F
};
// Merge local config overrides (config.local.json in same dir — git-ignored, never committed)
try {
const localCfgPath = path.join(__dirname, 'config.local.json');
if (fs.existsSync(localCfgPath)) {
Object.assign(WEATHER_CONFIG, JSON.parse(fs.readFileSync(localCfgPath, 'utf8')));
}
} catch (e) { /* ignore parse errors */ }
// Claude Usage API Configuration (cross-platform)
const USAGE_API_CONFIG = {
credentialsPath: path.join(os.homedir(), '.claude', 'usage-credentials.json'),
cacheDuration: 10 * 60 * 1000, // 10 minutes (reduces rate limiting)
apiBaseUrl: 'claude.ai'
};
// Unified usage cache (replaces separate plist and five-hour caches)
const USAGE_API_CACHE_FILE = path.join(os.tmpdir(), 'claude-statusline-api-usage.json');
const USAGE_API_CACHE_DURATION = USAGE_API_CONFIG.cacheDuration;
// Backoff state file — shared across all sessions to avoid hammering a rate-limited API
const USAGE_API_BACKOFF_FILE = path.join(os.tmpdir(), 'claude-statusline-api-backoff.json');
const BACKOFF_BASE_MS = USAGE_API_CONFIG.cacheDuration; // starts at cache TTL (10m)
const BACKOFF_MAX_MS = 60 * 60 * 1000; // cap at 60 minutes
// ========================================
// END CONFIGURATION SECTION
// ========================================
// Function to create a progress bar for context window
function createProgressBar(percentage, width = 20) {
const filled = Math.round((percentage / 100) * width);
const empty = width - filled;
const bar = '█'.repeat(filled) + '░'.repeat(empty);
let color = colors.green;
if (percentage > 70) color = colors.yellow;
if (percentage > 85) color = colors.red;
return `${color}${bar}${colors.reset} ${percentage.toFixed(1)}%`;
}
// Token estimation based on model type
const TOKEN_LIMITS = {
'claude-haiku-4-5': 200000,
'claude-sonnet-4-6': 1000000,
'claude-sonnet-4-5': 1000000,
'claude-sonnet-4': 1000000,
'claude-opus-4-8': 200000,
'claude-opus-4-7': 200000,
'claude-opus-4-5': 200000,
'claude-opus-4-1': 200000,
'claude-opus-4': 200000,
'opus': 200000,
'claude-3-opus': 200000,
'claude-3-sonnet': 200000,
'claude-3-haiku': 200000,
'claude-2.1': 200000,
'claude-2': 100000,
'claude-instant': 100000,
'default': 200000
};
// Map a model id to a friendly display name. The id is authoritative — Claude
// Code sometimes passes a stale `display_name` (e.g. shows "Opus 4.7" right after
// switching to 4.8), so we derive from the id first and only fall back to the
// provided display_name / a generic label when the id is unrecognized.
function modelDisplayName(modelId, providedDisplayName) {
const id = (modelId || '').toLowerCase();
// Ordered longest-match-first so 'claude-opus-4-8' wins over 'claude-opus-4'.
const MAP = [
['claude-opus-4-8', 'Opus 4.8'],
['claude-opus-4-7', 'Opus 4.7'],
['claude-opus-4-5', 'Opus 4.5'],
['claude-opus-4-1', 'Opus 4.1'],
['claude-opus-4', 'Opus 4'],
['claude-sonnet-4-6', 'Sonnet 4.6'],
['claude-sonnet-4-5', 'Sonnet 4.5'],
['claude-sonnet-4', 'Sonnet 4'],
['claude-haiku-4-5', 'Haiku 4.5'],
['claude-3-opus', 'Opus 3'],
['claude-3-sonnet', 'Sonnet 3'],
['claude-3-haiku', 'Haiku 3'],
];
for (const [key, name] of MAP) {
if (id.includes(key)) return name;
}
// Unknown id: strip any "(1M context)" suffix from the provided name and use it.
if (providedDisplayName) {
return providedDisplayName
.replace(/\s*[\(\[\-]\s*1\s*m(\s+context)?\s*[\)\]]?\s*$/i, '')
.replace(/\s+1m\s+context\s*$/i, '')
.trim();
}
return 'Claude';
}
// Resolve the transcript path robustly, EVERYWHERE by default. The harness may pass
// a transcript_path that doesn't resolve as-given on Windows: backslash escaping, or
// the C:\ <-> F:\ junction (projects live at F:\claude\... but Claude may key the
// project dir under either drive letter). When that happens the context counter falls
// back to the fake '~ 1% [--/200K]' estimate. This resolver makes the real counter work
// regardless of drive/junction/naming.
//
// Strategy:
// 1. Use transcript_path as-given if it exists.
// 2. Try slash-normalized and every drive-letter variant of it.
// 3. Fall back to scanning ~/.claude/projects: find the encoded dir for cwd across all
// drive letters; if none, match any project dir whose name ENDS WITH the project's
// basename. Pick the dir holding the most-recently-modified *.jsonl.
function resolveTranscriptPath(data) {
const DRIVES = ['C', 'F', 'D', 'E', 'G'];
const projectsRoot = path.join(os.homedir(), '.claude', 'projects');
const tp = data.transcript_path;
// ---- 1 & 2: the given path, normalized, and across drive letters ----
if (tp) {
const fwd = tp.replace(/\\/g, '/');
const variants = new Set([tp, fwd]);
variants.add(fwd.replace(/^([A-Za-z]):/, (m, d) => d.toUpperCase() + ':'));
for (const D of DRIVES) variants.add(fwd.replace(/^[A-Za-z]:/, D + ':'));
for (const p of variants) {
try { if (p && fs.existsSync(p)) return p; } catch {}
}
}
// ---- 3: scan project dirs ----
try {
const cwd = (data.workspace && data.workspace.current_dir) || data.cwd || process.cwd();
if (!fs.existsSync(projectsRoot)) return tp;
const allDirs = fs.readdirSync(projectsRoot)
.filter(d => { try { return fs.statSync(path.join(projectsRoot, d)).isDirectory(); } catch { return false; } });
// Claude encodes the project dir name by replacing each : \ / . with '-'
// (consecutive dashes preserved): F:\claude\selfie-gen -> F--claude-selfie-gen
const encoded = cwd.replace(/[:\\/.]/g, '-');
const encodedNoDrive = encoded.replace(/^[A-Za-z]-/, ''); // -claude-selfie-gen
const basename = path.basename(cwd.replace(/\\/g, '/')); // selfie-gen
// Candidate dir names, best-match first.
const exact = new Set([encoded]);
for (const D of DRIVES) exact.add(D + '-' + encodedNoDrive);
const pickNewestJsonl = (dirName) => {
const dir = path.join(projectsRoot, dirName);
let js;
try { js = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')); } catch { return null; }
if (!js.length) return null;
const best = js
.map(f => ({ p: path.join(dir, f), m: fs.statSync(path.join(dir, f)).mtimeMs }))
.sort((a, b) => b.m - a.m)[0];
return best ? best.p : null;
};
// a) exact encoded match (any drive letter)
for (const name of exact) {
if (allDirs.includes(name)) { const hit = pickNewestJsonl(name); if (hit) return hit; }
}
// b) any project dir ending with the encoded-no-drive tail (handles odd drive prefixes)
const tailMatches = allDirs.filter(d => d.endsWith(encodedNoDrive) && encodedNoDrive.length > 3);
// c) else any project dir ending with the basename (loosest, still scoped to this project)
const baseMatches = allDirs.filter(d => d.endsWith('-' + basename) || d.endsWith(basename));
const ranked = [...tailMatches, ...baseMatches.filter(d => !tailMatches.includes(d))];
let bestPath = null, bestMtime = -1;
for (const name of ranked) {
const hit = pickNewestJsonl(name);
if (hit) {
const m = fs.statSync(hit).mtimeMs;
if (m > bestMtime) { bestMtime = m; bestPath = hit; }
}
}
if (bestPath) return bestPath;
} catch {}
return tp; // graceful fallback (existsSync fails -> '~' estimate, as before)
}
// Function to read actual token usage from Claude Code transcript
function getActualTokenUsage(data) {
// Get model-specific token limit
const modelId = data.model?.id || 'opus';
const modelName = modelId.toLowerCase();
let tokenLimit = TOKEN_LIMITS.default;
for (const [key, limit] of Object.entries(TOKEN_LIMITS)) {
if (modelName.includes(key)) {
tokenLimit = limit;
break;
}
}
// 1M context variant detection: model IDs like 'claude-opus-4-7[1m]' or '...-1m'
const is1M = /\[1m\]/i.test(modelId) || /[-_]1m\b/i.test(modelId);
if (is1M) tokenLimit = 1000000;
// Try to read the transcript file to get actual token usage
const transcriptPath = resolveTranscriptPath(data);
if (transcriptPath && fs.existsSync(transcriptPath)) {
try {
const transcriptData = fs.readFileSync(transcriptPath, 'utf8');
const lines = transcriptData.split('\n').filter(line => line.trim());
let mostRecentUsage = null;
let messageCount = 0;
// Parse each message in the transcript to find the most recent assistant response with usage data
for (const line of lines) {
try {
const message = JSON.parse(line);
// Look for assistant messages with usage data
if (message.type === 'assistant' && message.message && message.message.usage) {
messageCount++;
mostRecentUsage = message.message.usage;
}
} catch (parseError) {
// Skip invalid JSON lines
continue;
}
}
// If we found actual usage data, calculate real context usage
if (mostRecentUsage) {
// Calculate total context usage with overhead multiplier (from L3's accurate formula)
// Total = (input_tokens + cache_creation + cache_read + output_tokens) * 1.2
// 1.2x accounts for MCP tool schemas (~40K), system prompts, plugin configs
const inputTokens = mostRecentUsage.input_tokens || 0;
const cacheCreationTokens = mostRecentUsage.cache_creation_input_tokens || 0;
const cacheReadTokens = mostRecentUsage.cache_read_input_tokens || 0;
const outputTokens = mostRecentUsage.output_tokens || 0;
const OVERHEAD_MULTIPLIER = 1.2;
const rawContextUsed = inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens;
const contextUsed = Math.round(rawContextUsed * OVERHEAD_MULTIPLIER);
const percentage = (contextUsed / tokenLimit) * 100;
// Debug logging if enabled
if (process.env.DEBUG_STATUSLINE) {
console.error(`[DEBUG] REAL Token Usage:
Input tokens: ${inputTokens}
Cache creation tokens: ${cacheCreationTokens}
Cache read tokens: ${cacheReadTokens}
Output tokens: ${outputTokens}
Raw context: ${rawContextUsed}
With overhead (1.2x): ${contextUsed}
Token limit: ${tokenLimit}
Percentage: ${percentage.toFixed(2)}%
Messages: ${messageCount}`);
}
return {
percentage: percentage,
used: contextUsed,
limit: tokenLimit,
is1M: is1M,
isActual: true,
debug: {
inputTokens,
cacheCreationTokens,
cacheReadTokens,
outputTokens,
rawContext: rawContextUsed,
messageCount,
totalContext: contextUsed
}
};
}
} catch (error) {
// Fall back to estimation if transcript reading fails
console.error('Error reading transcript for token usage:', error.message);
}
}
// Fallback to estimation if transcript not available
return estimateContextUsageFallback(data, tokenLimit, is1M);
}
// Fallback estimation function (simplified version of old function)
function estimateContextUsageFallback(data, tokenLimit, is1M = false) {
const sessionId = data.session_id || '';
// More realistic estimation for new conversations
// Most conversations start small and grow gradually
const sessionHash = sessionId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
// Start with a small base (1-5% for new conversations)
const basePercentage = 1 + (sessionHash % 4); // 1-5% base
// Add modest growth based on session characteristics
const sessionLength = sessionId.length;
const lengthBoost = Math.min(10, sessionLength * 0.2); // Up to 10% based on session ID length
let percentage = basePercentage + lengthBoost;
// Clamp to realistic range for most conversations
percentage = Math.max(0.5, Math.min(25, percentage)); // 0.5% to 25% range
// Convert percentage to actual token count
const estimatedUsed = Math.floor((percentage / 100) * tokenLimit);
// Ensure minimum realistic token usage (at least 1K for any active conversation)
const minimumUsed = Math.max(estimatedUsed, 1000);
const finalPercentage = (minimumUsed / tokenLimit) * 100;
return {
percentage: finalPercentage,
used: minimumUsed,
limit: tokenLimit,
is1M: is1M,
isActual: false
};
}
// ============================================================================
// API COST CALCULATION (direct API billing — independent of plan quotas)
// ============================================================================
// Per-million-token rates in USD. Anthropic standard pricing as of May 2026.
// 1M-context Opus 4.7 uses 2x rates (premium tier). Cache write = same as input,
// cache read = 10% of input. Override any value via CLAUDE_PRICING env JSON.
const PRICING = {
// model id pattern -> { input, output, cacheWriteMul, cacheReadMul }
'claude-opus-4-8': { input: 15.00, output: 75.00, ctxPremiumMul: 2.0 },
'claude-opus-4-7': { input: 15.00, output: 75.00, ctxPremiumMul: 2.0 },
'claude-opus-4-5': { input: 15.00, output: 75.00, ctxPremiumMul: 2.0 },
'claude-opus-4-1': { input: 15.00, output: 75.00, ctxPremiumMul: 2.0 },
'claude-opus-4': { input: 15.00, output: 75.00, ctxPremiumMul: 2.0 },
'claude-sonnet-4-6': { input: 3.00, output: 15.00, ctxPremiumMul: 2.0 }, // >200K tier
'claude-sonnet-4-5': { input: 3.00, output: 15.00, ctxPremiumMul: 2.0 },
'claude-sonnet-4': { input: 3.00, output: 15.00, ctxPremiumMul: 2.0 },
'claude-haiku-4-5': { input: 1.00, output: 5.00, ctxPremiumMul: 1.0 },
'opus': { input: 15.00, output: 75.00, ctxPremiumMul: 1.0 },
'claude-3-opus': { input: 15.00, output: 75.00, ctxPremiumMul: 1.0 },
'claude-3-sonnet': { input: 3.00, output: 15.00, ctxPremiumMul: 1.0 },
'claude-3-haiku': { input: 0.25, output: 1.25, ctxPremiumMul: 1.0 },
'default': { input: 3.00, output: 15.00, ctxPremiumMul: 1.0 }
};
const CACHE_WRITE_MULTIPLIER = 1.25; // cache_creation costs 1.25x input
const CACHE_READ_MULTIPLIER = 0.10; // cache_read costs 0.10x input
function getPricing(modelId) {
const id = (modelId || '').toLowerCase();
// Apply env override if set
try {
if (process.env.CLAUDE_PRICING) {
const override = JSON.parse(process.env.CLAUDE_PRICING);
for (const key of Object.keys(override)) {
if (id.includes(key.toLowerCase())) return override[key];
}
}
} catch (_) {}
for (const key of Object.keys(PRICING)) {
if (key !== 'default' && id.includes(key)) return PRICING[key];
}
return PRICING.default;
}
// ============================================================================
// SESSION METADATA (start time, model switches, todos, MCP count)
// ============================================================================
function getSessionMeta(data) {
const result = { sessionStart: null, elapsedStr: null, modelSwitched: false, uniqueModels: [] };
const transcriptPath = data.transcript_path;
if (!transcriptPath || !fs.existsSync(transcriptPath)) return result;
try {
const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
const models = new Set();
for (const line of lines) {
try {
const msg = JSON.parse(line);
if (!result.sessionStart && msg.timestamp && msg.type === 'user') {
result.sessionStart = new Date(msg.timestamp);
}
if (msg.type === 'assistant' && msg.message?.model && msg.message.model !== '<synthetic>') {
models.add(msg.message.model);
}
} catch (_) {}
}
result.uniqueModels = [...models];
result.modelSwitched = models.size > 1;
if (result.sessionStart) {
const elapsedMs = Date.now() - result.sessionStart.getTime();
const mins = Math.floor(elapsedMs / 60000);
const hours = Math.floor(mins / 60);
result.elapsedStr = hours > 0 ? `${hours}h${mins % 60}m` : `${mins}m`;
}
} catch (_) {}
return result;
}
function getSessionTodos(sessionId) {
if (!sessionId) return null;
const taskDir = path.join(os.homedir(), '.claude', 'tasks', sessionId);
if (!fs.existsSync(taskDir)) return null;
try {
const files = fs.readdirSync(taskDir).filter(f => f.endsWith('.json') && f !== '.highwatermark');
let pending = 0, inProgress = 0, total = 0;
for (const f of files) {
try {
const task = JSON.parse(fs.readFileSync(path.join(taskDir, f), 'utf8'));
if (task.status === 'deleted') continue;
total++;
if (task.status === 'pending') pending++;
else if (task.status === 'in_progress') inProgress++;
} catch (_) {}
}
if (total === 0) return null;
const open = pending + inProgress;
const done = total - open;
return { open, done, total, inProgress };
} catch (_) { return null; }
}
function getMcpCount() {
let count = 0;
try {
const local = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude', 'settings.local.json'), 'utf8'));
if (Array.isArray(local.enabledMcpjsonServers)) count += local.enabledMcpjsonServers.length;
} catch (_) {}
try {
const mcpJson = path.join(os.homedir(), '.mcp.json');
if (fs.existsSync(mcpJson)) {
const mcp = JSON.parse(fs.readFileSync(mcpJson, 'utf8'));
count += Object.keys(mcp.mcpServers || {}).length;
}
} catch (_) {}
return count > 0 ? count : null;
}
// Sum cost across the entire session transcript. Handles model-switching mid-session.
// Tier-aware: when an assistant message used the 1M variant, applies ctxPremiumMul.
function calculateSessionCost(data) {
const transcriptPath = data.transcript_path;
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
return { total: 0, byModel: {}, isActual: false };
}
try {
const lines = fs.readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean);
const byModel = {};
let total = 0;
for (const line of lines) {
try {
const msg = JSON.parse(line);
if (msg.type !== 'assistant' || !msg.message?.usage) continue;
const modelId = msg.message.model || data.model?.id || 'default';
if (modelId === '<synthetic>') continue; // skip Claude Code system msgs
const u = msg.message.usage;
const p = getPricing(modelId);
// Detect tier: 1M variant or context >200K triggers premium pricing
const is1M = /\[1m\]/i.test(modelId) || /[-_]1m\b/i.test(modelId);
const totalCtx = (u.input_tokens||0) + (u.cache_creation_input_tokens||0) + (u.cache_read_input_tokens||0);
const premiumMul = (is1M || totalCtx > 200000) ? p.ctxPremiumMul : 1.0;
const inCost = ((u.input_tokens||0) / 1_000_000) * p.input * premiumMul;
const ccCost = ((u.cache_creation_input_tokens||0) / 1_000_000) * p.input * CACHE_WRITE_MULTIPLIER * premiumMul;
const crCost = ((u.cache_read_input_tokens||0) / 1_000_000) * p.input * CACHE_READ_MULTIPLIER * premiumMul;
const outCost = ((u.output_tokens||0) / 1_000_000) * p.output * premiumMul;
const subtotal = inCost + ccCost + crCost + outCost;
total += subtotal;
if (!byModel[modelId]) byModel[modelId] = 0;
byModel[modelId] += subtotal;
} catch (_) {}
}
return { total, byModel, isActual: true };
} catch (e) {
return { total: 0, byModel: {}, isActual: false, error: e.message };
}
}
function formatCost(usd) {
if (usd === 0) return '$0.00';
if (usd < 0.01) return '<$0.01';
if (usd < 10) return `$${usd.toFixed(2)}`;
if (usd < 100) return `$${usd.toFixed(1)}`;
return `$${Math.round(usd)}`;
}
// ============================================================================
// EFFORT LEVEL detection
// ============================================================================
function getEffortLevel(data) {
// Priority: data payload > env > settings.json
if (data?.effortLevel) return String(data.effortLevel);
if (process.env.CLAUDE_CODE_EFFORT_LEVEL) return process.env.CLAUDE_CODE_EFFORT_LEVEL;
try {
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
if (settings.effortLevel) return settings.effortLevel;
}
} catch (_) {}
return null;
}
// Truncate string to max length with ellipsis
function truncate(str, max) {
if (str.length <= max) return str;
return str.slice(0, max - 1) + '…';
}
// Measure visible terminal width of a string (accounts for wide emoji = 2 cols)
function visWidth(str) {
// Strip ANSI escape codes first
const clean = str.replace(/\x1b\[[0-9;]*m/g, '');
let w = 0;
for (const ch of clean) {
const cp = ch.codePointAt(0);
// Emoji and wide chars: surrogate pairs (astral plane), emoji presentation, misc symbols
if (cp > 0xFFFF || (cp >= 0x1F000 && cp <= 0x1FFFF) || (cp >= 0x2600 && cp <= 0x27BF) ||
(cp >= 0x2300 && cp <= 0x23FF) || (cp >= 0xFE00 && cp <= 0xFE0F) ||
(cp >= 0x200D && cp <= 0x200D)) {
// Variation selectors and ZWJ are zero-width
if ((cp >= 0xFE00 && cp <= 0xFE0F) || cp === 0x200D) {
w += 0;
} else {
w += 2;
}
} else {
w += 1;
}
}
return w;
}
// Function to get git branch info
function getGitBranch(dir) {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000
}).trim();
const statusOut = execSync('git status --porcelain', {
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000
}).trim();
const lines = statusOut ? statusOut.split('\n') : [];
const staged = lines.filter(l => l.length >= 2 && l[0] !== ' ' && l[0] !== '?').length;
const dirty = lines.some(l => l.length >= 2 && (l[1] !== ' ' || l[0] === '?'));
let ahead = 0;
try {
const aheadOut = execSync('git rev-list @{u}..HEAD --count', {
cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000
}).trim();
ahead = parseInt(aheadOut, 10) || 0;
} catch (e) { /* no upstream configured */ }
const indicators = [`🌿 ${branch}`];
if (dirty) indicators.push('✏️');
if (staged > 0) indicators.push(`📦 ${staged}`);
if (ahead > 0) indicators.push(`🚀 ${ahead}`);
return { parts: indicators };
} catch (error) {
return null;
}
}
// Weather cache configuration
const WEATHER_CACHE_FILE = path.join(os.tmpdir(), 'claude-statusline-weather.json');
const WEATHER_CACHE_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
const CACHE_VERSION = '1.0'; // Cache version for invalidation
// Bitcoin cache configuration
const BITCOIN_CACHE_FILE = path.join(os.tmpdir(), 'claude-statusline-bitcoin.json');
const BITCOIN_CACHE_DURATION = 15 * 60 * 1000; // 15 minutes in milliseconds
// HTTP agent for connection pooling
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 5,
keepAliveMsecs: 30000
});
// Function to make HTTPS requests with timeout and retry logic
async function httpsRequestWithRetry(url, maxRetries = 3, timeout = 3000) {
let lastError;
let delay = 100; // Initial delay for exponential backoff
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
if (attempt > 0) {
// Exponential backoff: 100ms, 200ms, 400ms
await new Promise(resolve => setTimeout(resolve, delay));
delay *= 2;
}
const data = await httpsRequest(url, timeout);
return data;
} catch (error) {
lastError = error;
// Continue to next retry
}
}
throw lastError;
}
// Function to make HTTPS requests with timeout
function httpsRequest(url, timeout = 3000) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
timeout,
agent: httpsAgent
};
const req = https.get(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(data));
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.on('error', reject);
});
}
// Function to get Bitcoin price
async function getBitcoinPrice() {
try {
// Check cache first
const cached = getCachedBitcoin();
if (cached) {
return cached;
}
// Fetch from Coinbase API with retry logic
const response = await httpsRequestWithRetry('https://api.coinbase.com/v2/exchange-rates?currency=BTC', 3, 3000);
const data = JSON.parse(response);
const price = parseFloat(data.data.rates.USD);
const formatted = `₿$${(price / 1000).toFixed(0)}k`;
// Cache the result
cacheBitcoin(formatted);
return formatted;
} catch (error) {
return '₿--k';
}
}
// Function to get cached Bitcoin data
function getCachedBitcoin() {
try {
if (fs.existsSync(BITCOIN_CACHE_FILE)) {
const cached = JSON.parse(fs.readFileSync(BITCOIN_CACHE_FILE, 'utf8'));
const now = Date.now();
if (now - cached.timestamp < BITCOIN_CACHE_DURATION) {
return cached.data;
}
}
} catch (error) {
// Ignore cache errors and fetch fresh data
}
return null;
}
// Function to cache Bitcoin data
function cacheBitcoin(bitcoinData) {
try {
const cacheData = {
timestamp: Date.now(),
data: bitcoinData
};
fs.writeFileSync(BITCOIN_CACHE_FILE, JSON.stringify(cacheData));
} catch (error) {
// Ignore cache write errors
}
}
// ========================================
// CLAUDE USAGE API FUNCTIONS (CROSS-PLATFORM)
// ========================================
// Function to get cached API usage data
function getCachedApiUsage(staleOk = false) {
try {
if (fs.existsSync(USAGE_API_CACHE_FILE)) {
const cached = JSON.parse(fs.readFileSync(USAGE_API_CACHE_FILE, 'utf8'));
const now = Date.now();
if (now - cached.timestamp < USAGE_API_CACHE_DURATION) {
return cached.data;
}
// Return stale data if caller accepts it (better than showing nothing)
if (staleOk) return cached.data;
}
} catch (error) {
// Ignore cache errors and fetch fresh data
}
return null;
}
// Function to cache API usage data
function cacheApiUsage(data) {
try {
const cacheData = {
timestamp: Date.now(),
data: data
};
fs.writeFileSync(USAGE_API_CACHE_FILE, JSON.stringify(cacheData));
} catch (error) {
// Ignore cache write errors
}
}
// Exponential backoff — shared across all Claude Code sessions via temp file.
// Prevents N sessions from hammering a rate-limited API every 10 minutes.
function isInBackoff() {
try {
if (fs.existsSync(USAGE_API_BACKOFF_FILE)) {
const state = JSON.parse(fs.readFileSync(USAGE_API_BACKOFF_FILE, 'utf8'));
const elapsed = Date.now() - state.lastFailure;
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, state.failures - 1), BACKOFF_MAX_MS);
if (elapsed < delay) {
if (process.env.DEBUG_STATUSLINE) {
const remainSec = Math.round((delay - elapsed) / 1000);
console.error(`[DEBUG] API backoff active: ${state.failures} failures, ${remainSec}s remaining (delay=${Math.round(delay/1000)}s)`);
}
return true;
}
}
} catch (e) {}
return false;
}
function recordBackoffFailure() {
try {
let failures = 1;
if (fs.existsSync(USAGE_API_BACKOFF_FILE)) {
const state = JSON.parse(fs.readFileSync(USAGE_API_BACKOFF_FILE, 'utf8'));
failures = (state.failures || 0) + 1;
}
fs.writeFileSync(USAGE_API_BACKOFF_FILE, JSON.stringify({ lastFailure: Date.now(), failures }));
if (process.env.DEBUG_STATUSLINE) {
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, failures - 1), BACKOFF_MAX_MS);
console.error(`[DEBUG] API backoff recorded: failure #${failures}, next retry in ${Math.round(delay/1000)}s`);
}
} catch (e) {}
}
function clearBackoff() {
try { fs.unlinkSync(USAGE_API_BACKOFF_FILE); } catch (e) {}
}
// Returns human-readable backoff info for statusline display, or null if not in backoff
function getBackoffInfo() {
try {
if (fs.existsSync(USAGE_API_BACKOFF_FILE)) {
const state = JSON.parse(fs.readFileSync(USAGE_API_BACKOFF_FILE, 'utf8'));
const elapsed = Date.now() - state.lastFailure;
const delay = Math.min(BACKOFF_BASE_MS * Math.pow(2, state.failures - 1), BACKOFF_MAX_MS);
const remainMs = delay - elapsed;
if (remainMs > 0) {
const mins = Math.ceil(remainMs / 60000);
return { failures: state.failures, retryIn: mins <= 60 ? `${mins}m` : `${Math.round(mins/60)}h` };
}
}
} catch (e) {}
return null;
}
// Function to load credentials from config file
function loadUsageCredentials() {
try {
if (!fs.existsSync(USAGE_API_CONFIG.credentialsPath)) {
return null;
}
const creds = JSON.parse(fs.readFileSync(USAGE_API_CONFIG.credentialsPath, 'utf8'));
if (!creds.sessionKey || !creds.orgId) {
return null;
}
return creds;
} catch (error) {
return null;
}
}
// Function to fetch usage data via Swift script (macOS) or fallback
// Swift bypasses Cloudflare protection that blocks Node.js https requests
function fetchClaudeApiUsage() {
return new Promise((resolve, reject) => {
// Check cache first
const cached = getCachedApiUsage();
if (cached) {
resolve(cached);
return;
}
// Respect shared backoff — don't hit the API if another session just failed
if (isInBackoff()) {
const stale = getCachedApiUsage(true);
if (stale) { resolve(stale); return; }
reject(new Error('API in backoff'));
return;
}
// On macOS, use Swift script which bypasses Cloudflare
const scriptPath = path.join(os.homedir(), '.claude/fetch-claude-usage.swift');
if (os.platform() === 'darwin' && fs.existsSync(scriptPath)) {
try {
const output = execSync(scriptPath, {
encoding: 'utf8',
timeout: 5000,
stdio: ['ignore', 'pipe', 'ignore']
}).trim();
// Parse output: "5|2026-02-04T13:00:00|45|2026-02-08T03:00:00|1|2026-02-08T19:00:00"
// Format: 5H_UTIL|5H_RESET|WEEKLY_UTIL|WEEKLY_RESET|SONNET_UTIL|SONNET_RESET
const parts = output.split('|');
if (parts[0].startsWith('ERROR')) {
reject(new Error(output));
return;
}
if (parts.length >= 6) {
const usageData = {
fiveHour: {
utilization: parseInt(parts[0], 10) || 0,
resetsAt: parts[1] || null
},
weekly: {
utilization: parseInt(parts[2], 10) || 0,
resetsAt: parts[3] || null
},
sonnet: {
utilization: parseInt(parts[4], 10) || 0,
resetsAt: parts[5] || null
}
};
// Debug output
if (process.env.DEBUG_STATUSLINE) {
console.error(`[DEBUG] Claude API Usage (via Swift):
5h: ${usageData.fiveHour.utilization}% (resets: ${usageData.fiveHour.resetsAt})
Weekly: ${usageData.weekly.utilization}% (resets: ${usageData.weekly.resetsAt})
Sonnet: ${usageData.sonnet.utilization}% (resets: ${usageData.sonnet.resetsAt})`);
}
// Cache the result
cacheApiUsage(usageData);
clearBackoff();
resolve(usageData);
return;
}
} catch (error) {
if (process.env.DEBUG_STATUSLINE) {
console.error(`[DEBUG] Swift script error: ${error.message}`);
}
}
}
// On Windows, use curl which bypasses Cloudflare better than Node.js https
if (os.platform() === 'win32') {
const creds = loadUsageCredentials();
if (creds && creds.sessionKey && creds.orgId) {
try {
const curlCmd = `curl -s -H "Cookie: sessionKey=${creds.sessionKey}" -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" -H "Accept: application/json" "https://claude.ai/api/organizations/${creds.orgId}/usage"`;
const output = execSync(curlCmd, {
encoding: 'utf8',
timeout: 10000,
stdio: ['ignore', 'pipe', 'ignore'],
shell: true
}).trim();
const json = JSON.parse(output);
// Reject error responses (rate limit, auth failures, etc.)
if (json.error || !json.five_hour) {
throw new Error(`API error: ${json.error?.message || 'missing five_hour field'}`);
}
const fiveHour = json.five_hour;
const sevenDay = json.seven_day || {};
const sevenDaySonnet = json.seven_day_sonnet || {};
const usageData = {
fiveHour: {
utilization: Math.round(fiveHour.utilization || 0),
resetsAt: fiveHour.resets_at || null
},
weekly: {
utilization: Math.round(sevenDay.utilization || 0),
resetsAt: sevenDay.resets_at || null
},
sonnet: {
utilization: Math.round(sevenDaySonnet.utilization || 0),
resetsAt: sevenDaySonnet.resets_at || null
}
};
if (process.env.DEBUG_STATUSLINE) {
console.error(`[DEBUG] Claude API Usage (via curl/Windows):
5h: ${usageData.fiveHour.utilization}% (resets: ${usageData.fiveHour.resetsAt})
Weekly: ${usageData.weekly.utilization}% (resets: ${usageData.weekly.resetsAt})
Sonnet: ${usageData.sonnet.utilization}% (resets: ${usageData.sonnet.resetsAt})`);
}
cacheApiUsage(usageData);
clearBackoff();
resolve(usageData);
return;
} catch (error) {
if (process.env.DEBUG_STATUSLINE) {
console.error(`[DEBUG] Windows curl error: ${error.message}`);
}
// API-level errors (rate limit, auth) → reject immediately, don't double-hit via HTTPS
if (error.message && error.message.startsWith('API error:')) {
reject(error);
return;
}
// Network/parse errors → fall through to Node.js fallback
}
}
}
// Fallback: Try direct API call (may be blocked by Cloudflare on some networks)
const creds = loadUsageCredentials();
if (!creds) {
reject(new Error('No credentials configured'));
return;
}
// Validate orgId (no path traversal)
if (creds.orgId.includes('..') || creds.orgId.includes('/')) {
reject(new Error('Invalid organization ID'));
return;
}
const options = {
hostname: USAGE_API_CONFIG.apiBaseUrl,
port: 443,
path: `/api/organizations/${creds.orgId}/usage`,
method: 'GET',
headers: {
'Cookie': `sessionKey=${creds.sessionKey}`,
'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept-Language': 'en-US,en;q=0.9',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
},
timeout: 5000,
agent: httpsAgent
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
if (res.statusCode !== 200) {
reject(new Error(`API returned ${res.statusCode}`));
return;
}
try {
const json = JSON.parse(data);
// Reject error responses (rate limit, auth failures, etc.)
if (json.error || !json.five_hour) {
reject(new Error(`API error: ${json.error?.message || 'missing five_hour field'}`));
return;
}
// Extract all usage data from API response
const fiveHour = json.five_hour;
const sevenDay = json.seven_day || {};
const sevenDaySonnet = json.seven_day_sonnet || {};
const usageData = {
fiveHour: {
utilization: Math.round(fiveHour.utilization || 0),
resetsAt: fiveHour.resets_at || null
},
weekly: {
utilization: Math.round(sevenDay.utilization || 0),
resetsAt: sevenDay.resets_at || null
},
sonnet: {
utilization: Math.round(sevenDaySonnet.utilization || 0),
resetsAt: sevenDaySonnet.resets_at || null
}
};
// Debug output
if (process.env.DEBUG_STATUSLINE) {
console.error(`[DEBUG] Claude API Usage (direct):
5h: ${usageData.fiveHour.utilization}% (resets: ${usageData.fiveHour.resetsAt})
Weekly: ${usageData.weekly.utilization}% (resets: ${usageData.weekly.resetsAt})
Sonnet: ${usageData.sonnet.utilization}% (resets: ${usageData.sonnet.resetsAt})`);
}
// Cache the result
cacheApiUsage(usageData);
clearBackoff();
resolve(usageData);
} catch (parseError) {
reject(new Error('Failed to parse API response'));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.on('error', reject);
req.end();
});
}
// Function to format time until reset (handles ISO timestamps from API)
function formatTimeUntilReset(resetTimestamp) {
try {
if (!resetTimestamp) return '';
// Parse ISO timestamp string (e.g., "2026-02-08T03:00:00")
let resetDate;
if (typeof resetTimestamp === 'string') {
resetDate = new Date(resetTimestamp);
} else if (typeof resetTimestamp === 'number') {
// Legacy: Convert Apple/Cocoa epoch (2001-01-01) to Unix epoch
resetDate = new Date((resetTimestamp + 978307200) * 1000);
} else {
return '';
}
// Calculate time remaining in milliseconds
const now = Date.now();
const remaining = resetDate.getTime() - now;
// If overdue or very soon
if (remaining <= 0) {
return 'soon';
}
// Convert to time units
const days = Math.floor(remaining / (1000 * 60 * 60 * 24));
const hours = Math.floor((remaining % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((remaining % (1000 * 60 * 60)) / (1000 * 60));
// Format based on time remaining
if (days > 0) {
return `${days}d ${hours}h`;
} else if (hours > 0) {
return `${hours}h ${minutes}m`;
} else if (minutes > 0) {
return `${minutes}m`;
} else {
return 'soon';
}
} catch (error) {
return '';
}
}
// Helper function to format tokens (reusing existing pattern)
function formatTokens(num) {
if (num >= 1000) {
return `${(num / 1000).toFixed(0)}K`;
}
return num.toString();
}
// ========================================
// LEGACY COMPATIBILITY (removed - now using unified API)
// ========================================
// Five-hour and weekly data now come from fetchClaudeApiUsage()
// Format ISO timestamp to HH:MM
function formatResetTime(isoTimestamp) {
if (!isoTimestamp) return 'unknown';
try {
const date = new Date(isoTimestamp);
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
} catch (error) {
return 'unknown';
}
}
// Create simple progress bar with ▓ and ░ characters.
// Clamp filled to [0, width] so percentages >100% (context over the limit) or any
// odd input never produce a negative .repeat() count (which throws RangeError and
// crashes the whole render, dropping to the '~' fallback).
function createSimpleProgressBar(percentage, width = 10) {
const pct = Number.isFinite(percentage) ? percentage : 0;
let filled = Math.round((pct / 100) * width);
filled = Math.max(0, Math.min(width, filled));
const empty = width - filled;
return '▓'.repeat(filled) + '░'.repeat(empty);
}
// Function to build compact usage tracker parts for combined line
// Now uses unified API data structure with fiveHour, weekly, and sonnet
function buildUsageTrackerLine(apiUsageData) {
// Returns object with five-hour and weekly parts (or null if no data)
if (!apiUsageData) return null;
const parts = {};
// Five-hour usage (if available) - compact version
if (apiUsageData.fiveHour && apiUsageData.fiveHour.utilization !== undefined) {
const percentage = apiUsageData.fiveHour.utilization;
const simpleBar = createSimpleProgressBar(percentage, 5); // Shorter bar
const resetTime = formatResetTime(apiUsageData.fiveHour.resetsAt);
// Color code based on thresholds
let barColor = colors.green;
if (percentage > 70) barColor = colors.yellow;
if (percentage > 85) barColor = colors.red;
parts.fiveHour = `${colors.dim}5h:${colors.reset} ${barColor}${simpleBar}${colors.reset} ${percentage}% ${colors.dim}→${resetTime}${colors.reset}`;
}
// Weekly usage (if available) - compact version
if (apiUsageData.weekly && apiUsageData.weekly.utilization !== undefined) {
const percentage = apiUsageData.weekly.utilization;
// Color code based on percentage
let wkColor = colors.green;
if (percentage > 70) wkColor = colors.yellow;
if (percentage > 85) wkColor = colors.red;
const progressBar = createSimpleProgressBar(percentage, 6); // Shorter bar
const timeUntilReset = formatTimeUntilReset(apiUsageData.weekly.resetsAt);
const timeDisplay = timeUntilReset ? `${colors.dim}→${timeUntilReset}${colors.reset}` : '';
parts.weekly = `${colors.dim}Wk:${colors.reset} ${wkColor}${progressBar}${colors.reset} ${percentage}% ${timeDisplay}`;
}
// Sonnet weekly usage (if available and > 0) - compact version
if (apiUsageData.sonnet && apiUsageData.sonnet.utilization > 0) {
const percentage = apiUsageData.sonnet.utilization;
let snColor = colors.green;
if (percentage > 70) snColor = colors.yellow;
if (percentage > 85) snColor = colors.red;
const progressBar = createSimpleProgressBar(percentage, 4);
parts.sonnet = `${colors.dim}Sn:${colors.reset} ${snColor}${progressBar}${colors.reset} ${percentage}%`;
}
// Return parts object (or null if no data)
if (Object.keys(parts).length === 0) return null;
return parts;
}
// Function to get cached weather data
function getCachedWeather() {
try {
if (fs.existsSync(WEATHER_CACHE_FILE)) {
const cached = JSON.parse(fs.readFileSync(WEATHER_CACHE_FILE, 'utf8'));
const now = Date.now();
// Check cache version and expiration
if (cached.version === CACHE_VERSION && now - cached.timestamp < WEATHER_CACHE_DURATION) {
return cached.data;
}
}
} catch (error) {
// Ignore cache errors and fetch fresh data
}
return null;
}
// Function to cache weather data
function cacheWeather(weatherData) {
try {
const cacheData = {
timestamp: Date.now(),
version: CACHE_VERSION,
data: weatherData
};
fs.writeFileSync(WEATHER_CACHE_FILE, JSON.stringify(cacheData));
} catch (error) {
// Ignore cache write errors
}
}
// Function to fetch real weather data
async function fetchRealWeather() {
try {
// Check cache first
const cached = getCachedWeather();
if (cached) {
return cached;
}
// Try multiple approaches for better reliability
let weatherData = null;
let error = null;
// Primary: Try zip code with retry logic
if (WEATHER_CONFIG.zipCode) try {
const response1 = await httpsRequestWithRetry(`https://wttr.in/${WEATHER_CONFIG.zipCode}?format=%t|%C|%l&${WEATHER_CONFIG.celsius ? 'm' : 'u'}&q`, 2, 3000);
weatherData = response1.trim();
if (weatherData && !weatherData.includes('Unknown location') && !weatherData.includes('not found')) {
// Success with zip code
} else {
throw new Error('Invalid response from zip code');
}
} catch (err) {
error = err;
}
// Fallback: Try city name
if (!weatherData) try {
const response2 = await httpsRequestWithRetry(`https://wttr.in/${WEATHER_CONFIG.cityName}?format=%t|%C|%l&${WEATHER_CONFIG.celsius ? 'm' : 'u'}&q`, 2, 3000);
weatherData = response2.trim();
if (!weatherData || weatherData.includes('Unknown location') || weatherData.includes('not found')) {
throw new Error('Invalid response from city name');
}
} catch (err2) {
// Final fallback: Try coordinates
try {
const response3 = await httpsRequestWithRetry(`https://wttr.in/${WEATHER_CONFIG.latitude},${WEATHER_CONFIG.longitude}?format=%t|%C|%l&${WEATHER_CONFIG.celsius ? 'm' : 'u'}&q`, 2, 3000);
weatherData = response3.trim();
if (!weatherData || weatherData.includes('Unknown location') || weatherData.includes('not found')) {
throw new Error('All weather sources failed');
}
} catch (err3) {
throw new Error('All weather sources failed');
}
}
const [temp, condition, location] = weatherData.split('|');
// Validate the temperature format
if (!temp || !temp.trim().match(/[+-]?\d+°[FC]/)) {
throw new Error('Invalid temperature format');
}
// Get weather emoji based on condition
const weatherEmoji = getWeatherEmoji(condition || 'Unknown');
const formattedWeather = {
display: `${weatherEmoji} ${temp.trim()}`,
condition: (condition || 'Unknown').trim(),
location: (location || WEATHER_CONFIG.defaultLocation).trim()
};
// Cache the result
cacheWeather(formattedWeather);
return formattedWeather;
} catch (error) {
// Return fallback weather but with more informative display
return {
display: '🌡️ --°F',
condition: 'Unknown',
location: WEATHER_CONFIG.defaultLocation
};
}
}
// Function to get weather emoji based on condition
function getWeatherEmoji(condition) {
const conditionLower = condition.toLowerCase();
if (conditionLower.includes('sunny') || conditionLower.includes('clear')) {
return '☀️';
} else if (conditionLower.includes('partly cloudy') || conditionLower.includes('partial')) {
return '⛅';
} else if (conditionLower.includes('cloudy') || conditionLower.includes('overcast')) {
return '☁️';
} else if (conditionLower.includes('rain') || conditionLower.includes('drizzle')) {
return '🌧️';
} else if (conditionLower.includes('storm') || conditionLower.includes('thunder')) {
return '⛈️';
} else if (conditionLower.includes('snow') || conditionLower.includes('blizzard')) {
return '❄️';
} else if (conditionLower.includes('fog') || conditionLower.includes('mist')) {
return '🌫️';
} else if (conditionLower.includes('wind')) {
return '💨';
} else {
return '🌡️';
}
}
// Function to get weather (with real API integration)
function getWeather() {
// Return cached weather synchronously if available, otherwise return fallback
const cached = getCachedWeather();
if (cached) {
return cached.display;
}
// Start async fetch in background but don't wait for it
fetchRealWeather().catch(() => {
// Ignore errors in background fetch
});
return '🌡️ --°'; // Fallback while loading
}
// Function to format date and time
function getDateTime() {
const now = new Date();
// Date format: MM/DD/YY
const date = now.toLocaleDateString('en-US', {
month: '2-digit',
day: '2-digit',
year: '2-digit'
});
// Time format: HH:MM:SS
const time = now.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
// Day of week (abbreviated)
const dayOfWeek = now.toLocaleDateString('en-US', { weekday: 'short' });
return {
date,
time,
dayOfWeek
};
}
// Main function to generate status line
async function generateStatusLine() {
let input = '';
return new Promise((resolve) => {
// Read from stdin
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
const chunk = process.stdin.read();
if (chunk !== null) {
input += chunk;
}
});
process.stdin.on('end', async () => {
try {
const data = input ? JSON.parse(input) : {};
// Debug mode: Log the data structure to understand what Claude Code sends
// Set DEBUG_STATUSLINE=1 environment variable to enable debug logging
if (process.env.DEBUG_STATUSLINE) {
const debugData = {
timestamp: new Date().toISOString(),
inputData: data,
transcriptExists: data.transcript_path && fs.existsSync(data.transcript_path),
transcriptPath: data.transcript_path
};
fs.writeFileSync(path.join(os.tmpdir(), 'claude-statusline-debug.json'), JSON.stringify(debugData, null, 2));
}
// Extract information.
// Derive the display name from the model *id* (authoritative) rather than
// trusting display_name, which can lag after a /model switch. The helper
// also strips any "(1M context)" suffix so the [1M] badge isn't doubled.
const model = modelDisplayName(data.model?.id, data.model?.display_name);
const currentDir = data.workspace?.current_dir || process.cwd();
const dirName = path.basename(currentDir);
const contextUsage = getActualTokenUsage(data);
const dateTime = getDateTime();
const gitBranch = getGitBranch(currentDir);
// CACHE-ONLY reads: never await network fetches (they cause timeouts).
// Background refreshes fire-and-forget for next invocation.
const weather = getWeather();
fetchRealWeather().catch(() => {}); // refresh cache in background
const btcPrice = getCachedBitcoin() || '₿--k';
getBitcoinPrice().catch(() => {}); // refresh cache in background
const sessionCost = calculateSessionCost(data);
const effortLevel = getEffortLevel(data);
const sessionMeta = getSessionMeta(data);
const todos = getSessionTodos(data.session_id);
const mcpCount = getMcpCount();
// Only fetch plan-quota data when the user opts in (saves a network round-trip per render).
let apiUsageData = null;
if (process.env.STATUSLINE_SHOW_PLAN_QUOTA === '1') {
apiUsageData = getCachedApiUsage() || getCachedApiUsage(true);
fetchClaudeApiUsage().catch((err) => {
const msg = String(err?.message || '').toLowerCase();
const skipBackoff = msg.includes('no credentials') || msg.includes('invalid organization') || msg.includes('api in backoff');
if (!skipBackoff) recordBackoffFailure();
});
}
// ── Fixed 3-line layout ───────────────────────────────────────────────
// Line 1: 🤖 Model [200K/1M] 🎯 effort │ 📁 dir │ 🌿 branch
// Line 2: 📅 date Day │ ⏰ time │ ₿btc │ 🌧️ weather │ ⚡
// Line 3: Ctx: indicator bar% [used/limit] │ ⏱ timer │ 🔧 ver
// ─────────────────────────────────────────────────────────────────────
// ── Line 1 ────────────────────────────────────────────────────────────
const windowLabel = contextUsage.limit >= 1000000 ? '[1M]' : '[200K]';
const eColor = effortLevel === 'high' ? colors.red
: effortLevel === 'medium' ? colors.yellow
: colors.dim;
const modelPart = `${colors.bright}${colors.cyan}🤖 ${model} ${windowLabel}${colors.reset}`;
const sep = ` ${colors.dim}│${colors.reset} `;
const effortPart = effortLevel
? `${sep}${eColor}🎯 ${effortLevel}${colors.reset}${sep}`
: sep;
const line1Tail = [`${colors.blue}📁 ${dirName}${colors.reset}`];
if (gitBranch) {
for (const part of gitBranch.parts) {
line1Tail.push(`${colors.green}${part}${colors.reset}`);
}
}
if (todos) {
const todoColor = todos.inProgress > 0 ? colors.yellow : todos.open > 0 ? colors.cyan : colors.dim;
line1Tail.push(`${todoColor}📋 ${todos.open}/${todos.total}${colors.reset}`);
}
if (mcpCount) {
line1Tail.push(`${colors.dim}⚙️ ${mcpCount}${colors.reset}`);
}
// ── Line 2 ────────────────────────────────────────────────────────────
const line2Parts = [
`${colors.magenta}📅 ${dateTime.date} ${dateTime.dayOfWeek}${colors.reset}`,
`${colors.yellow}⏰ ${dateTime.time}${colors.reset}`,
`${colors.bright}${colors.orange}${btcPrice}${colors.reset}`,
`${colors.green}${weather}${colors.reset}`
];
const line2 = line2Parts.join(` ${colors.dim}│${colors.reset} `);
// ── Line 3 ────────────────────────────────────────────────────────────
// Session timer (cost intentionally omitted — user is on Max plan)
const timerPart = sessionMeta.elapsedStr
? `${colors.yellow}⏱ ${sessionMeta.elapsedStr}${colors.reset}`
: null;
// Context bar — bold+bright when >80%
const formatTokens = (num) => num >= 1000 ? `${(num / 1000).toFixed(0)}K` : num.toString();
const limitLabel = contextUsage.limit >= 1000000 ? '1M' : `${Math.round(contextUsage.limit / 1000)}K`;
const tokenDisplay = `${colors.dim}[${formatTokens(contextUsage.used)}/${limitLabel}]${colors.reset}`;
const indicator = contextUsage.isActual
? `${colors.bright}${colors.green}●${colors.reset}`
: `${colors.dim}${colors.yellow}~${colors.reset}`;
let ctxColor = colors.green;
if (contextUsage.percentage > 70) ctxColor = colors.yellow;
if (contextUsage.percentage > 80) ctxColor = colors.red;
const ctxBright = contextUsage.percentage > 80 ? colors.bright : '';
const contextPart = `${colors.dim}Ctx:${colors.reset} ${indicator} ${ctxBright}${ctxColor}${createSimpleProgressBar(contextUsage.percentage, 6)}${colors.reset} ${ctxBright}${ctxColor}${contextUsage.percentage.toFixed(0)}%${colors.reset} ${tokenDisplay}`;
const versionStr = `${colors.magenta}🔧 ${detectAndCacheVersion(data.version)}${colors.reset}`;
const line3Parts = [contextPart];
if (timerPart) line3Parts.push(timerPart);
// Plan-quota bars (opt-in: STATUSLINE_SHOW_PLAN_QUOTA=1)
const showPlanQuota = process.env.STATUSLINE_SHOW_PLAN_QUOTA === '1';
if (showPlanQuota) {
const usageTrackerParts = buildUsageTrackerLine(apiUsageData);
if (usageTrackerParts) {
if (usageTrackerParts.fiveHour) line3Parts.push(usageTrackerParts.fiveHour);
if (usageTrackerParts.weekly) line3Parts.push(usageTrackerParts.weekly);
if (usageTrackerParts.sonnet) line3Parts.push(usageTrackerParts.sonnet);
} else if (process.env.STATUSLINE_SHOW_QUOTA_DIAGNOSTICS === '1') {
const backoff = getBackoffInfo();
if (backoff) {
line3Parts.push(`${colors.dim}5h:${colors.reset} ${colors.yellow}rate limited${colors.reset} ${colors.dim}retry ${backoff.retryIn} (#${backoff.failures})${colors.reset}`);
} else if (!getCachedApiUsage(true)) {
line3Parts.push(`${colors.dim}5h:${colors.reset} ${colors.dim}loading...${colors.reset}`);
}
}
}
line3Parts.push(versionStr);
const line3 = line3Parts.join(` ${colors.dim}│${colors.reset} `);
// ── Output ────────────────────────────────────────────────────────────
console.log(modelPart + effortPart + line1Tail.join(sep));
console.log(line2);
console.log(line3);
resolve();
} catch (error) {
// Fallback if JSON parsing fails
const fallbackDateTime = getDateTime();
const fallbackDir = path.basename(process.cwd());
const fallbackGit = getGitBranch(process.cwd());
const fallbackWeather = getWeather();
const fallbackBtc = getCachedBitcoin() || '₿--k';
// Fallback: same 3-line fixed layout. No model data available in this
// path (JSON parse failed), so show a neutral label rather than a
// hardcoded version that could be wrong.
const fbLine1 = [
`${colors.bright}${colors.cyan}🤖 Claude [200K]${colors.reset}`,
`${colors.blue}📁 ${fallbackDir}${colors.reset}`
];
if (fallbackGit) {
for (const part of fallbackGit.parts) {
fbLine1.push(`${colors.green}${part}${colors.reset}`);
}
}
const fbLine2 = [
`${colors.magenta}📅 ${fallbackDateTime.date} ${fallbackDateTime.dayOfWeek}${colors.reset}`,
`${colors.yellow}⏰ ${fallbackDateTime.time}${colors.reset}`,
`${colors.yellow}${fallbackBtc}${colors.reset}`,
`${colors.green}${fallbackWeather}${colors.reset}`,
`${colors.bright}${colors.yellow}⚡${colors.reset}`
].join(` ${colors.dim}│${colors.reset} `);
const fbLine3 = `${colors.dim}Ctx:${colors.reset} ${colors.dim}${colors.yellow}~${colors.reset} ${createSimpleProgressBar(1, 6)} 1% ${colors.dim}[--/200K]${colors.reset} ${colors.dim}│${colors.reset} ${colors.magenta}🔧 ${detectAndCacheVersion(null)}${colors.reset}`;
console.log(fbLine1.join(' │ '));
console.log(fbLine2);
console.log(fbLine3);
resolve();
}
});
});
}
// Preload weather and Bitcoin data in background on script startup
function preloadData() {
// Only fetch if cache is expired or missing
const cachedWeather = getCachedWeather();
if (!cachedWeather) {
fetchRealWeather().catch(() => {
// Ignore errors in background preload
});
}
// Preload Bitcoin price if not cached
const cachedBitcoin = getCachedBitcoin();
if (!cachedBitcoin) {
getBitcoinPrice().catch(() => {
// Ignore errors in background preload
});
}
}
// Test function for debugging token calculations
function testTokenCalculation() {
// Test with mock data
const mockData = {
model: { id: 'claude-opus-4-8[1m]', display_name: 'Opus 4.8' },
session_id: 'test-session-123',
transcript_path: null, // Force fallback estimation
workspace: { current_dir: process.cwd() },
version: 'v1.0.0'
};
const result = getActualTokenUsage(mockData);
console.log('Token calculation test result:');
console.log(` Used: ${result.used} tokens`);
console.log(` Limit: ${result.limit} tokens`);
console.log(` Percentage: ${result.percentage.toFixed(2)}%`);
console.log(` Is actual: ${result.isActual}`);
console.log(` Formatted: ${result.used >= 1000 ? `${(result.used / 1000).toFixed(0)}K` : result.used}/${result.limit >= 1000 ? `${(result.limit / 1000).toFixed(0)}K` : result.limit}`);
}
// Version cache configuration (avoids slow execSync every call)
const VERSION_CACHE_FILE = path.join(os.tmpdir(), 'claude-statusline-version.json');
const VERSION_CACHE_DURATION = 60 * 60 * 1000; // 1 hour
function getCachedVersion() {
try {
if (fs.existsSync(VERSION_CACHE_FILE)) {
const cache = JSON.parse(fs.readFileSync(VERSION_CACHE_FILE, 'utf8'));
if (Date.now() - cache.timestamp < VERSION_CACHE_DURATION) {
return cache.version;
}
}
} catch (e) {}
return null;
}
function detectAndCacheVersion(dataVersion) {
if (dataVersion) {
try { fs.writeFileSync(VERSION_CACHE_FILE, JSON.stringify({ version: dataVersion, timestamp: Date.now() })); } catch (e) {}
return dataVersion;
}
const cached = getCachedVersion();
if (cached) return cached;
try {
const v = execSync('claude --version 2>&1', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500, windowsHide: true }).trim();
const m = v.match(/(?:claude\/)?v?(\d+\.\d+\.\d+)/);
if (m) {
try { fs.writeFileSync(VERSION_CACHE_FILE, JSON.stringify({ version: m[1], timestamp: Date.now() })); } catch (e) {}
return m[1];
}
} catch (e) {}
return '?.?.?';
}
// Run the status line generator or test
if (require.main === module) {
// Check for test mode
if (process.argv.includes('--test')) {
testTokenCalculation();
return;
}
// HARD DEADLINE: Output something within 3s. Claude Code kills statusline commands at ~5s.
let outputProduced = false;
const deadline = setTimeout(() => {
if (!outputProduced) {
outputProduced = true;
const tw = process.stdout.columns || 120;
const mw = (p) => visWidth(p.join(' │ '));
const dt = getDateTime();
const dir = path.basename(process.cwd());
const git = getGitBranch(process.cwd());
const w = getWeather();
const btc = getCachedBitcoin() || '₿--k';
const ver = getCachedVersion() || '?.?.?';
const core = [
`${colors.bright}${colors.cyan}🤖 Claude${colors.reset}`,
`${colors.bright}${colors.yellow}⚡${colors.reset}`,
`${colors.blue}📁 ${dir}${colors.reset}`
];
if (git) {
for (const part of git.parts) {
core.push(`${colors.green}${part}${colors.reset}`);
}
}
const dlExtras = [
`${colors.magenta}📅 ${dt.date}${colors.reset}`,
`${colors.yellow}⏰ ${dt.time}${colors.reset}`,
`${colors.cyan}📆 ${dt.dayOfWeek}${colors.reset}`,
`${colors.green}${w}${colors.reset}`,
`${colors.yellow}${btc}${colors.reset}`,
`${colors.magenta}🔧 ${ver}${colors.reset}`
];
const dlOverflow = [];
for (const e of dlExtras) {
if (mw([...core, e]) <= tw - 2) core.push(e);
else dlOverflow.push(e);
}
console.log(core.join(' │ '));
const ctxLine = `${colors.dim}Ctx:${colors.reset} ${colors.dim}${colors.yellow}~${colors.reset} ${colors.green}${createSimpleProgressBar(5, 6)}${colors.reset} ~5%`;
if (dlOverflow.length > 0) {
console.log(dlOverflow.join(' │ ') + ` ${colors.dim}│${colors.reset} ` + ctxLine);
} else {
console.log(ctxLine);
}
process.exit(0);
}
}, 3000);
deadline.unref();
// Start data preload in background
preloadData();
// Generate status line
generateStatusLine().then(() => {
outputProduced = true;
clearTimeout(deadline);
}).catch((err) => {
if (!outputProduced) {
outputProduced = true;
clearTimeout(deadline);
console.error(err);
}
});
}
module.exports = { generateStatusLine, colors, createProgressBar, fetchRealWeather, getCachedWeather, getBitcoinPrice };
More status lines
app · main · Opus-4.8 · effort high · ctx 22% · 5h 26% ↻2h6m · 7d 7% ↻2d0h ✧ ███████████▋ 73% 🗝 Ultima Weapon ✦ app ∙ main
♥ ████████████████████████ 100% 「SAVE POINT」 ✶ Limit Form ◉ 41
╭─ CONTEXT ────────────────────────────────────────────╮
│ ████▊░░░░░░░░░░░░░░░░░ 22% of 200K · GOOD │
├─ STATS ──────────────────────────────────────────────┤
│ ↓ 44.0K ↑ 1.4K │ $0.41 │ ⏱ 10m 12s │ +128/-34 │
├─ REPO ───────────────────────────────────────────────┤
│ ◆ Opus 4.8 · main · 17:27 │
╰──────────────────────────────────────────────────────╯
Opus 4.8 | main | ██░░░░░░░░ 22.0% | 44k / 200k | 02:07 26% | Wed 7%
Opus 4.8 [high] ~/app main
17:27:05 | ⛁ ██░░░░░░░░ 22% | 5h ●◔○○○ 26% ↻2h7m | 7d ◔○○○○○○ 7% ↻2d1h │ $0.41 ⏱ 10m
Opus 4.8 │ main │ app │ ▓▓░░░░░░░░ 44k/200k │ $0.41