Last active
April 26, 2026 19:15
-
-
Save tarasyarema/65949b462de1c06077411e4f59d9cf8b 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 | |
| // 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