Skip to content

Instantly share code, notes, and snippets.

@tarasyarema
Last active April 26, 2026 19:15
Show Gist options
  • Select an option

  • Save tarasyarema/65949b462de1c06077411e4f59d9cf8b to your computer and use it in GitHub Desktop.

Select an option

Save tarasyarema/65949b462de1c06077411e4f59d9cf8b to your computer and use it in GitHub Desktop.
~/.claude/statusline.ts
#!/usr/bin/env bun
// Canonical source: https://gist.github.com/tarasyarema/65949b462de1c06077411e4f59d9cf8b
// Local changes should be synced to that gist.
import { execSync } from 'child_process';
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { basename, join } from 'path';
interface StatusInput {
session_id: string;
transcript_path: string;
cwd: string;
model: {
id: string;
display_name: string;
};
workspace: {
current_dir: string;
project_dir: string;
};
version: string;
output_style: {
name: string;
};
context_window: {
total_input_tokens: number;
total_output_tokens: number;
context_window_size: number;
current_usage: {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
} | null;
used_percentage: number | null;
remaining_percentage: number | null;
};
}
// ANSI color codes
const colors = {
reset: '\x1b[0m',
dim: '\x1b[2m',
bold: '\x1b[1m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
brightGreen: '\x1b[92m',
brightYellow: '\x1b[93m',
brightBlue: '\x1b[94m',
brightMagenta: '\x1b[95m',
brightCyan: '\x1b[96m',
brightRed: '\x1b[91m',
};
const c = colors;
// Read stdin
const input: StatusInput = await Bun.stdin.json();
// Format number with k/M suffix. Auto-picks decimals: 1-digit precision below
// 10k/10M, 0 below that, and strips trailing ".0" so "1.0M" renders as "1M".
function formatTokens(n: number, decimals?: number): string {
const autoDecimals = (val: number, unit: number) =>
val / unit >= 10 ? 0 : 1;
let out: string;
if (n >= 1_000_000) {
const d = decimals ?? autoDecimals(n, 1_000_000);
out = (n / 1_000_000).toFixed(d) + 'M';
} else if (n >= 1_000) {
const d = decimals ?? autoDecimals(n, 1_000);
out = (n / 1_000).toFixed(d) + 'k';
} else {
return n.toString();
}
return out.replace(/\.0(?=[kM])/, '');
}
// Calculate context usage percentage — prefer the pre-calculated field which
// correctly reflects the actual context window size per model (e.g. Opus 1M).
// Fall back to manual calculation if the field is absent (older CC versions).
let pctUsed = 0;
if (input.context_window.used_percentage != null) {
pctUsed = Math.round(input.context_window.used_percentage);
} else if (input.context_window.current_usage) {
const usage = input.context_window.current_usage;
const totalTokens = input.context_window.context_window_size;
const currentTokens = usage.input_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens;
pctUsed = Math.round((currentTokens / totalTokens) * 100);
}
// Create colored progress bar (10 characters)
function createProgressBar(pct: number): string {
const filled = Math.round((pct / 100) * 10);
const empty = 10 - filled;
// Color based on usage level: green < 40%, yellow 40-60%, red > 60%
let barColor = c.brightGreen;
if (pct >= 40 && pct < 60) barColor = c.brightYellow;
if (pct >= 60) barColor = c.brightRed;
const pipes = barColor + '|'.repeat(filled) + c.reset;
const spaces = c.dim + '·'.repeat(empty) + c.reset;
return `${c.dim}[${c.reset}${pipes}${spaces}${c.dim}]${c.reset}`;
}
const contextBar = createProgressBar(pctUsed);
// ─── Pricing ────────────────────────────────────────────────────────────────
// Rates in $ per 1M tokens: { input, cacheWrite5m, cacheWrite1h, cacheRead, output }
interface TierRates { input: number; cacheWrite5m: number; cacheWrite1h: number; cacheRead: number; output: number; }
const TIERS: Record<string, TierRates> = {
'opus-new': { input: 5, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.50, output: 25 },
'opus-old': { input: 15, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.50, output: 75 },
'sonnet': { input: 3, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.30, output: 15 },
'haiku-new': { input: 1, cacheWrite5m: 1.25, cacheWrite1h: 2, cacheRead: 0.10, output: 5 },
'haiku-old': { input: 0.80, cacheWrite5m: 1.00, cacheWrite1h: 1.60, cacheRead: 0.08, output: 4 },
'opus-3-dep': { input: 15, cacheWrite5m: 18.75, cacheWrite1h: 30, cacheRead: 1.50, output: 75 },
'haiku-3': { input: 0.25, cacheWrite5m: 0.30, cacheWrite1h: 0.50, cacheRead: 0.03, output: 1.25 },
};
function getTierRates(modelId: string): TierRates {
const id = modelId.toLowerCase();
// Claude 3 opus/haiku (deprecated legacy)
if (/claude-3-opus|opus-3[^-]|opus-3$/.test(id)) return TIERS['opus-3-dep']!;
if (/claude-3-5-haiku|haiku-3-5/.test(id)) return TIERS['haiku-old']!;
if (/claude-3-haiku|haiku-3[^-]|haiku-3$/.test(id)) return TIERS['haiku-3']!;
// Claude 4+ haiku
if (id.includes('haiku')) return TIERS['haiku-new']!;
// Opus 4.0 / 4.1 (deprecated) — only match exact "-4-0" or "-4-1" suffix variants
if (/opus-4-0|opus-4-1/.test(id)) return TIERS['opus-old']!;
// Opus 4.5+ (new pricing)
if (id.includes('opus')) return TIERS['opus-new']!;
// Sonnet (all versions: 3.7, 4, 4.5, 4.6…)
if (id.includes('sonnet')) return TIERS['sonnet']!;
return TIERS['sonnet']!; // unknown → sonnet fallback
}
interface UsageBucket {
input_tokens: number;
output_tokens: number;
cache_creation_input_tokens: number;
cache_read_input_tokens: number;
cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number };
}
function calcCostFromBucket(usage: UsageBucket, rates: TierRates): number {
const cw5m = usage.cache_creation?.ephemeral_5m_input_tokens ?? usage.cache_creation_input_tokens;
const cw1h = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
return (
(usage.input_tokens / 1_000_000) * rates.input +
(cw5m / 1_000_000) * rates.cacheWrite5m +
(cw1h / 1_000_000) * rates.cacheWrite1h +
(usage.cache_read_input_tokens / 1_000_000) * rates.cacheRead +
(usage.output_tokens / 1_000_000) * rates.output
);
}
// ─── Main session cost ───────────────────────────────────────────────────────
const mainRates = getTierRates(input.model.id);
const totalInput = input.context_window.total_input_tokens;
const totalOutput = input.context_window.total_output_tokens;
// Estimate cache tokens from current_usage ratios applied to session totals
let cacheWrite = 0;
let cacheRead = 0;
if (input.context_window.current_usage) {
const currentUsage = input.context_window.current_usage;
const currentTotal = currentUsage.input_tokens + currentUsage.cache_creation_input_tokens + currentUsage.cache_read_input_tokens;
if (currentTotal > 0) {
cacheWrite = Math.round(totalInput * (currentUsage.cache_creation_input_tokens / currentTotal));
cacheRead = Math.round(totalInput * (currentUsage.cache_read_input_tokens / currentTotal));
}
}
const regularInput = totalInput - cacheWrite - cacheRead;
const cost =
(regularInput / 1_000_000) * mainRates.input +
(totalOutput / 1_000_000) * mainRates.output +
(cacheWrite / 1_000_000) * mainRates.cacheWrite5m +
(cacheRead / 1_000_000) * mainRates.cacheRead;
const costStr = `${c.green}$${cost.toFixed(4)}${c.reset}`;
// Calculate session time using transcript file creation time
function getSessionTime(): string {
try {
const transcriptPath = input.transcript_path;
if (existsSync(transcriptPath)) {
const stats = statSync(transcriptPath);
const startTime = stats.birthtime.getTime();
if (!isNaN(startTime)) {
return formatDuration(Date.now() - startTime);
}
}
} catch {
// Ignore errors
}
return '--:--';
}
function formatDuration(ms: number): string {
if (isNaN(ms) || ms < 0) return '--:--';
const hours = Math.floor(ms / 3600000);
const minutes = Math.floor((ms % 3600000) / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
if (hours > 0) {
return `${hours}h ${minutes}m`;
} else {
return `${minutes}m ${seconds}s`;
}
}
const sessionTime = getSessionTime();
// Get project path display
function getProjectPath(): string {
const currentDir = input.workspace.current_dir;
const currentName = basename(currentDir);
// Get git root directory
let gitRoot: string | null = null;
try {
gitRoot = execSync('git rev-parse --show-toplevel 2>/dev/null', {
cwd: currentDir,
encoding: 'utf-8'
}).trim();
} catch {
// Not in a git repo
}
if (gitRoot) {
const rootName = basename(gitRoot);
// If at git root, just show root name
if (currentDir === gitRoot) {
return rootName;
}
// If in subdirectory, check depth
if (currentDir.startsWith(gitRoot + '/')) {
const relativePath = currentDir.slice(gitRoot.length + 1);
const depth = relativePath.split('/').length;
if (depth === 1) {
// Immediate subdirectory: root/dir
return `${rootName}/${c.brightCyan}${currentName}`;
} else {
// Deeper: root/../dir
return `${rootName}/${c.dim}../${c.reset}${c.brightCyan}${currentName}`;
}
}
}
// Fallback: just current directory name
return currentName;
}
const projectPath = `${c.brightCyan}${getProjectPath()}${c.reset}`;
// Get git branch
function getGitBranch(): string {
try {
const branch = execSync('git rev-parse --abbrev-ref HEAD 2>/dev/null', {
cwd: input.workspace.current_dir,
encoding: 'utf-8'
}).trim();
return branch;
} catch {
return '';
}
}
const gitBranch = getGitBranch();
// Format model name
const modelName = `${c.magenta}${input.model.display_name}${c.reset}`;
// Format percentage with color
let pctColor = c.brightGreen;
if (pctUsed >= 40 && pctUsed < 60) pctColor = c.brightYellow;
if (pctUsed >= 60) pctColor = c.brightRed;
const pctStr = `${pctColor}${pctUsed}%${c.reset}`;
// Static overhead baseline derived from the first assistant turn in the
// transcript. The first turn's full context (input + cache_creation +
// cache_read) is all "static": system prompt + tools + skills + CLAUDE.md +
// the very first user message. cache_read can be non-zero on turn 1 when the
// prefix was already warmed by a sibling session — it's still static, just
// served from a cross-session cache. We treat this sum as "dflt" (per-session
// fixed cost) and everything above it as "eff" (effective conversation usage).
function getStaticBaseline(transcriptPath: string): number | null {
try {
if (!existsSync(transcriptPath)) return null;
const content = readFileSync(transcriptPath, 'utf-8');
for (const line of content.split('\n')) {
if (!line.trim()) continue;
let entry: any;
try { entry = JSON.parse(line); } catch { continue; }
const usage = entry?.message?.usage;
if (entry?.type === 'assistant' && usage) {
return (
(usage.input_tokens ?? 0) +
(usage.cache_creation_input_tokens ?? 0) +
(usage.cache_read_input_tokens ?? 0)
);
}
}
return null;
} catch {
return null;
}
}
// Format context window usage as: [<default>|<current>] <total>/<context> (<pct>)
// gray = default — first-turn static overhead (system/tools/skills/...)
// pctColor = current — total − baseline (conversation/tool delta)
// pctColor = total — actual context in use right now (default + current)
// dim = context — full context window size
// Example: "[49k|156k] 205k/1M (21%)"
function formatContextUsage(): string {
const windowSize = input.context_window.context_window_size;
const usage = input.context_window.current_usage;
const ctxStr = formatTokens(windowSize, 0);
if (!usage || pctUsed === 0) {
return `${c.dim}—/${ctxStr}${c.reset}`;
}
const total =
usage.input_tokens +
usage.cache_creation_input_tokens +
usage.cache_read_input_tokens;
const baseline = getStaticBaseline(input.transcript_path);
const currentTokens = baseline != null ? Math.max(0, total - baseline) : total;
const dfltStr = baseline != null ? formatTokens(baseline, 0) : '—';
const curStr = formatTokens(currentTokens, 0);
const totalStr = formatTokens(total, 0);
return (
`${c.dim}[${c.reset}` +
`${c.gray}${dfltStr}${c.reset}` +
`${c.dim}|${c.reset}` +
`${pctColor}${curStr}${c.reset}` +
`${c.dim}]${c.reset}` +
` ${pctColor}${totalStr}${c.reset}` +
`${c.dim}/${ctxStr}${c.reset}` +
` ${c.dim}(${c.reset}${pctStr}${c.dim})${c.reset}`
);
}
const contextUsageStr = formatContextUsage();
// Detect terminal width from multiple sources.
// process.stdout.columns is 0/undefined when stdout is not a TTY (subprocess invocation).
// $COLUMNS is set by the shell and propagated via tmux update-environment.
const envColumns = parseInt(process.env['COLUMNS'] ?? '0', 10);
const rawWidth = Math.max(
process.stdout.columns || 0,
isNaN(envColumns) ? 0 : envColumns,
);
// If the raw detected width is suspiciously narrow, tmux hasn't propagated real
// dimensions yet. Output nothing so Claude Code reserves 0 rows for the status bar
// rather than wrapping each line into many rows and crushing the input area.
// The statusline will auto-correct on the next render once the real width is known.
if (rawWidth > 0 && rawWidth < 30) {
process.exit(0);
}
// TODO: alternative heuristic to suppress the broken initial render —
// suppress output entirely until the first user message has been sent
// (i.e. transcript has at least one "human" turn). Would need to parse
// input.transcript_path to check for a human turn. Avoids the width race
// entirely since by the time the user types, tmux has settled on real dims.
// If no width signal at all (rawWidth === 0), fall back to 80 so non-tmux
// invocations still render. Cap at 400 to guard against corrupt env values.
const effectiveWidth = Math.min(rawWidth || 80, 400);
// Strip ANSI escape codes to measure the visible (printable) length of a string
// eslint-disable-next-line no-control-regex
function visibleLength(str: string): number {
return str.replace(/\x1b\[[0-9;]*m/g, '').length;
}
// Truncate a string with ANSI codes so its visible length does not exceed maxLen.
// Appends a reset code at the cut point to avoid color bleed.
function truncateLine(str: string, maxLen: number): string {
if (visibleLength(str) <= maxLen) return str;
// Walk through the string, tracking visible chars and skipping ANSI sequences
let visible = 0;
let i = 0;
// eslint-disable-next-line no-control-regex
const ansiRe = /\x1b\[[0-9;]*m/y;
while (i < str.length && visible < maxLen) {
ansiRe.lastIndex = i;
const match = ansiRe.exec(str);
if (match && match.index === i) {
// ANSI sequence — skip it (zero visible width)
i += match[0].length;
} else {
// Regular character — counts as one visible char
i++;
visible++;
}
}
return str.slice(0, i) + c.reset;
}
// Separator used across all lines
const sep = `${c.dim}·${c.reset}`;
// Canonical segment delimiter: two spaces, sep, two spaces. Lines are built by
// joining segments with this exact string, so wrapLine can split on it cleanly.
const segSep = ` ${sep} `;
// Wrap a line at ` · ` segment boundaries so long content flows onto multiple
// visual rows instead of being hard-truncated mid-word. If a single segment is
// itself longer than maxLen, fall back to truncating that segment.
function wrapLine(line: string, maxLen: number): string[] {
if (visibleLength(line) <= maxLen) return [line];
const segs = line.split(segSep);
const out: string[] = [];
let cur = '';
for (const seg of segs) {
const cand = cur ? `${cur}${segSep}${seg}` : seg;
if (visibleLength(cand) <= maxLen) {
cur = cand;
} else {
if (cur) out.push(cur);
cur = visibleLength(seg) > maxLen ? truncateLine(seg, maxLen) : seg;
}
}
if (cur) out.push(cur);
return out;
}
// ─── Sub-agent cost + token tracking ────────────────────────────────────────
// Encode cwd to the ~/.claude/projects/<encoded> format used by CC
function encodeCwd(cwd: string): string {
return cwd.replace(/\//g, '-');
}
interface SubagentSummary {
count: number;
totalInTokens: number;
totalOutTokens: number;
totalCost: number;
tierCounts: Record<string, number>; // tier name → agent count
}
// mtime cache: session_id → { mtime: number, summary: SubagentSummary }
const _subagentCache = new Map<string, { mtime: number; summary: SubagentSummary }>();
function classifyModelToTierName(modelId: string): string {
const id = modelId.toLowerCase();
if (/claude-3-opus|opus-3[^-]|opus-3$/.test(id)) return 'opus-3';
if (/claude-3-5-haiku|haiku-3-5/.test(id)) return 'haiku-3.5';
if (/claude-3-haiku|haiku-3[^-]|haiku-3$/.test(id)) return 'haiku-3';
if (id.includes('haiku')) return 'haiku';
if (/opus-4-0|opus-4-1/.test(id)) return 'opus-old';
if (id.includes('opus')) return 'opus';
if (id.includes('sonnet')) return 'sonnet';
return 'sonnet';
}
function getSubagentSummary(): SubagentSummary | null {
try {
const encoded = encodeCwd(input.cwd);
const subagentsDir = join(
process.env['HOME'] ?? '/tmp',
'.claude', 'projects', encoded, input.session_id, 'subagents'
);
if (!existsSync(subagentsDir)) return null;
const files = readdirSync(subagentsDir).filter(f => f.match(/^agent-.*\.jsonl$/));
if (files.length === 0) return null;
// Check overall dir mtime as a quick-exit guard
const dirMtime = statSync(subagentsDir).mtimeMs;
const cached = _subagentCache.get(input.session_id);
if (cached && cached.mtime === dirMtime) return cached.summary;
const summary: SubagentSummary = {
count: files.length,
totalInTokens: 0,
totalOutTokens: 0,
totalCost: 0,
tierCounts: {},
};
for (const file of files) {
const filePath = join(subagentsDir, file);
let fileContent: string;
try {
fileContent = readFileSync(filePath, 'utf-8');
} catch {
continue;
}
const lines = fileContent.split('\n').filter(l => l.trim());
let agentDominantTier = '';
let agentTierTokens: Record<string, number> = {};
for (const line of lines) {
let entry: any;
try { entry = JSON.parse(line); } catch { continue; }
const usage = entry?.message?.usage;
const modelId: string = entry?.message?.model ?? '';
if (!usage || !modelId) continue;
const rates = getTierRates(modelId);
const tierName = classifyModelToTierName(modelId);
const inTok = (usage.input_tokens ?? 0) as number;
const outTok = (usage.output_tokens ?? 0) as number;
const cw = (usage.cache_creation_input_tokens ?? 0) as number;
const cr = (usage.cache_read_input_tokens ?? 0) as number;
const cw5m = usage.cache_creation?.ephemeral_5m_input_tokens ?? cw;
const cw1h = usage.cache_creation?.ephemeral_1h_input_tokens ?? 0;
summary.totalInTokens += inTok + cw + cr;
summary.totalOutTokens += outTok;
summary.totalCost +=
(inTok / 1_000_000) * rates.input +
(cw5m / 1_000_000) * rates.cacheWrite5m +
(cw1h / 1_000_000) * rates.cacheWrite1h +
(cr / 1_000_000) * rates.cacheRead +
(outTok / 1_000_000) * rates.output;
agentTierTokens[tierName] = (agentTierTokens[tierName] ?? 0) + inTok + outTok;
}
// Dominant tier = most tokens used by this agent
let maxTok = 0;
for (const [tier, tok] of Object.entries(agentTierTokens)) {
if (tok > maxTok) { maxTok = tok; agentDominantTier = tier; }
}
if (agentDominantTier) {
summary.tierCounts[agentDominantTier] = (summary.tierCounts[agentDominantTier] ?? 0) + 1;
}
}
_subagentCache.set(input.session_id, { mtime: dirMtime, summary });
return summary;
} catch {
return null;
}
}
const subagentSummary = getSubagentSummary();
function formatSubagentSegment(s: SubagentSummary): string {
const countStr = `${c.brightBlue}${s.count}${c.reset}`;
const costStr = `${c.green}$${s.totalCost.toFixed(4)}${c.reset}`;
const inStr = formatTokens(s.totalInTokens);
const outStr = formatTokens(s.totalOutTokens);
const tierParts = Object.entries(s.tierCounts)
.sort((a, b) => b[1] - a[1])
.map(([tier, count]) => `${count}×${tier}`);
const tierStr = tierParts.length > 0 ? ` ${c.dim}(${tierParts.join(' ')})${c.reset}` : '';
return `${c.dim}subagents:${c.reset} ${countStr} ${costStr} ${c.dim}${inStr}${outStr}${c.reset}${tierStr}`;
}
// Two-line status:
// Line 1: project @ branch · [bar] pct · totalTokens · $cost
// Line 2: (time) · model
let line1 = projectPath;
if (gitBranch) {
line1 += ` ${c.dim}@${c.reset} ${c.brightBlue}${gitBranch}${c.reset}`;
}
line1 += ` ${sep} ${contextBar} ${contextUsageStr} ${sep} ${costStr}`;
let line2 = `${c.dim}(${sessionTime})${c.reset}`;
if (subagentSummary) {
line2 += ` ${sep} ${formatSubagentSegment(subagentSummary)}`;
}
line2 += ` ${sep} ${modelName}`;
const lines: string[] = [line1, line2];
process.stdout.write(lines.flatMap(l => wrapLine(l, effectiveWidth)).join('\n') + '\n');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment