Created
January 7, 2026 02:25
-
-
Save tarasyarema/8579b12ff8b2a1ce0bf8964efb75f987 to your computer and use it in GitHub Desktop.
~/.claude/statusline.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bun | |
| import { execSync } from 'child_process'; | |
| import { existsSync, readFileSync, statSync } from 'fs'; | |
| import { basename } 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; | |
| }; | |
| } | |
| // 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 | |
| function formatTokens(n: number, decimals: number = 1): string { | |
| if (n >= 1_000_000) { | |
| return (n / 1_000_000).toFixed(decimals) + 'M'; | |
| } else if (n >= 1_000) { | |
| return (n / 1_000).toFixed(decimals) + 'k'; | |
| } | |
| return n.toString(); | |
| } | |
| // Calculate context usage | |
| let pctUsed = 0; | |
| let currentTokens = 0; | |
| const totalTokens = input.context_window.context_window_size; | |
| let inputTokens = 0; | |
| let outputTokens = 0; | |
| let cachedTokens = 0; | |
| if (input.context_window.current_usage) { | |
| const usage = input.context_window.current_usage; | |
| inputTokens = usage.input_tokens; | |
| outputTokens = usage.output_tokens; | |
| cachedTokens = usage.cache_creation_input_tokens + usage.cache_read_input_tokens; | |
| currentTokens = inputTokens + cachedTokens; | |
| pctUsed = Math.round((currentTokens / totalTokens) * 100); | |
| } | |
| const totalSessionTokens = inputTokens + outputTokens + cachedTokens; | |
| // Calculate true session totals by reading transcript | |
| interface SessionTotals { | |
| input: number; | |
| output: number; | |
| cached: number; | |
| } | |
| function getSessionTotals(): SessionTotals { | |
| const totals: SessionTotals = { input: 0, output: 0, cached: 0 }; | |
| try { | |
| const transcriptPath = input.transcript_path; | |
| if (existsSync(transcriptPath)) { | |
| const content = readFileSync(transcriptPath, 'utf-8'); | |
| const lines = content.trim().split('\n'); | |
| for (const line of lines) { | |
| try { | |
| const entry = JSON.parse(line); | |
| const usage = entry.message?.usage; | |
| if (usage) { | |
| totals.input += usage.input_tokens || 0; | |
| totals.output += usage.output_tokens || 0; | |
| totals.cached += (usage.cache_creation_input_tokens || 0) + | |
| (usage.cache_read_input_tokens || 0); | |
| } | |
| } catch { | |
| // Skip malformed lines | |
| } | |
| } | |
| } | |
| } catch { | |
| // Ignore errors, return zeros | |
| } | |
| return totals; | |
| } | |
| const sessionTotals = getSessionTotals(); | |
| // 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); | |
| // Calculate session cost | |
| const COST_PER_1M_INPUT = 3.00; | |
| const COST_PER_1M_OUTPUT = 15.00; | |
| const COST_PER_1M_CACHE_WRITE = 3.75; | |
| const COST_PER_1M_CACHE_READ = 0.30; | |
| const totalInput = input.context_window.total_input_tokens; | |
| const totalOutput = input.context_window.total_output_tokens; | |
| // Estimate cache tokens | |
| 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) * COST_PER_1M_INPUT + | |
| (totalOutput / 1_000_000) * COST_PER_1M_OUTPUT + | |
| (cacheWrite / 1_000_000) * COST_PER_1M_CACHE_WRITE + | |
| (cacheRead / 1_000_000) * COST_PER_1M_CACHE_READ; | |
| const costStr = `${c.green}$${cost.toFixed(4)}${c.reset}`; | |
| // Calculate session time | |
| function getSessionTime(): string { | |
| try { | |
| const transcriptPath = input.transcript_path; | |
| if (existsSync(transcriptPath)) { | |
| const content = readFileSync(transcriptPath, 'utf-8'); | |
| const lines = content.trim().split('\n'); | |
| if (lines.length > 0 && lines[0]) { | |
| try { | |
| const firstLine = JSON.parse(lines[0]); | |
| if (firstLine.timestamp) { | |
| const startTime = new Date(firstLine.timestamp).getTime(); | |
| if (!isNaN(startTime)) { | |
| return formatDuration(Date.now() - startTime); | |
| } | |
| } | |
| } catch { | |
| // JSON parse failed, try stat | |
| } | |
| } | |
| // Fallback: use file creation time | |
| 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 ''; | |
| } | |
| } | |
| // Get git status | |
| function getGitStatus(): string { | |
| try { | |
| const status = execSync('git status --porcelain 2>/dev/null', { | |
| cwd: input.workspace.current_dir, | |
| encoding: 'utf-8' | |
| }).trim(); | |
| if (!status) { | |
| return `${c.brightGreen}✓${c.reset}`; | |
| } | |
| const indicators: string[] = []; | |
| const lines = status.split('\n'); | |
| let hasModified = false; | |
| let hasAdded = false; | |
| let hasDeleted = false; | |
| let hasUntracked = false; | |
| for (const line of lines) { | |
| const code = line.substring(0, 2); | |
| if (code.includes('M')) hasModified = true; | |
| if (code.includes('A')) hasAdded = true; | |
| if (code.includes('D')) hasDeleted = true; | |
| if (code === '??') hasUntracked = true; | |
| } | |
| if (hasModified) indicators.push(`${c.yellow}●${c.reset}`); | |
| if (hasAdded) indicators.push(`${c.green}+${c.reset}`); | |
| if (hasDeleted) indicators.push(`${c.yellow}-${c.reset}`); | |
| if (hasUntracked) indicators.push(`${c.gray}?${c.reset}`); | |
| // Get line changes (+/-) | |
| let additions = 0; | |
| let deletions = 0; | |
| try { | |
| // Staged changes | |
| const stagedDiff = execSync('git diff --cached --numstat 2>/dev/null', { | |
| cwd: input.workspace.current_dir, | |
| encoding: 'utf-8' | |
| }).trim(); | |
| // Unstaged changes | |
| const unstagedDiff = execSync('git diff --numstat 2>/dev/null', { | |
| cwd: input.workspace.current_dir, | |
| encoding: 'utf-8' | |
| }).trim(); | |
| for (const diff of [stagedDiff, unstagedDiff]) { | |
| if (diff) { | |
| for (const line of diff.split('\n')) { | |
| const [add, del] = line.split('\t'); | |
| if (add !== '-') additions += parseInt(add) || 0; | |
| if (del !== '-') deletions += parseInt(del) || 0; | |
| } | |
| } | |
| } | |
| } catch { | |
| // Ignore diff errors | |
| } | |
| if (additions > 0 || deletions > 0) { | |
| const parts: string[] = []; | |
| if (additions > 0) parts.push(`${c.green}+${additions}${c.reset}`); | |
| if (deletions > 0) parts.push(`${c.brightRed}-${deletions}${c.reset}`); | |
| indicators.push(parts.join(' ')); | |
| } | |
| return indicators.join(' '); | |
| } catch { | |
| return ''; | |
| } | |
| } | |
| const gitBranch = getGitBranch(); | |
| const gitStatus = getGitStatus(); | |
| // 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}`; | |
| // Format token counts for progress bar (color coded based on usage) | |
| const tokenInfo = `${c.dim}(${c.reset}${pctColor}${formatTokens(currentTokens)}${c.reset}${c.dim} / ${c.reset}${formatTokens(totalTokens)}${c.dim})${c.reset}`; | |
| // Build line 1: project @ branch status [progress] pct (tokens) cost (time) model | |
| let line1 = projectPath; | |
| if (gitBranch) { | |
| line1 += ` ${c.dim}@${c.reset} ${c.brightBlue}${gitBranch}${c.reset} ${gitStatus}`; | |
| } | |
| line1 += ` ${contextBar} ${pctStr} ${tokenInfo} ${costStr} ${c.dim}(${sessionTime})${c.reset} ${modelName}`; | |
| // Build line 2: ↑ input (session) ↓ output (session) ↯ cached (session) Σ total (session) | |
| const sessionTotalAll = sessionTotals.input + sessionTotals.output + sessionTotals.cached; | |
| const fmt = (n: number) => formatTokens(n, 2); | |
| const line2 = `${c.dim}↑${c.reset} ${c.cyan}${inputTokens.toLocaleString()}${c.reset} ${c.dim}(${fmt(sessionTotals.input)})${c.reset} ${c.dim}↓${c.reset} ${c.yellow}${outputTokens.toLocaleString()}${c.reset} ${c.dim}(${fmt(sessionTotals.output)})${c.reset} ${c.dim}↯${c.reset} ${c.blue}${cachedTokens.toLocaleString()}${c.reset} ${c.dim}(${fmt(sessionTotals.cached)})${c.reset} ${c.dim}Σ${c.reset} ${c.gray}${totalSessionTokens.toLocaleString()}${c.reset} ${c.dim}(${fmt(sessionTotalAll)})${c.reset}`; | |
| console.log(line1); | |
| console.log(line2); | |
| console.log(''); // Extra spacing at bottom |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add to
~/.claude/settings.json