Created
July 13, 2026 16:52
-
-
Save andrienko/9b8b831b1808185e77eb46eb153143fb to your computer and use it in GitHub Desktop.
Status line for claude
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 node | |
| // Claude Code statusline. | |
| // Renders: MODEL | ctx% (size) | 5h% (5h) | 7d% (7d) | cwd | |
| // Reads the statusLine JSON payload on stdin, prints one line on stdout. | |
| import os from 'node:os'; | |
| const config = { | |
| // Threshold colors for percentages. Values interpolate between these. | |
| pct: { | |
| green: [78, 201, 78], | |
| yellow: [214, 200, 74], | |
| red: [212, 90, 74], | |
| // Below greenMax: solid green. greenMax..yellowStart: lerp green->yellow. | |
| // yellowStart..yellowMax: solid yellow. yellowMax..redStart: lerp yellow->red. | |
| // At/above redStart: solid red. | |
| greenMax: 40, | |
| yellowStart: 60, | |
| yellowMax: 70, | |
| redStart: 90, | |
| }, | |
| model: { | |
| haiku: [225, 138, 138], // pale red | |
| sonnet: [92, 152, 240], // blue | |
| opus: [232, 232, 232], // white | |
| fallback: [200, 200, 200], | |
| }, | |
| separator: [92, 92, 92], // darkgrey | |
| muted: [140, 140, 140], // grey: parens and cwd | |
| cwdMaxLen: 30, // abbreviate path segments past this length | |
| }; | |
| const esc = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`; | |
| const reset = '\x1b[0m'; | |
| const paint = (rgb, text) => `${esc(rgb[0], rgb[1], rgb[2])}${text}${reset}`; | |
| const lerp = (a, b, t) => Math.round(a + (b - a) * t); | |
| const lerpRgb = (a, b, t) => [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)]; | |
| const pctColor = (p) => { | |
| const c = config.pct; | |
| if (p <= c.greenMax) return c.green; | |
| if (p < c.yellowStart) return lerpRgb(c.green, c.yellow, (p - c.greenMax) / (c.yellowStart - c.greenMax)); | |
| if (p <= c.yellowMax) return c.yellow; | |
| if (p < c.redStart) return lerpRgb(c.yellow, c.red, (p - c.yellowMax) / (c.redStart - c.yellowMax)); | |
| return c.red; | |
| }; | |
| // HSL -> RGB, h in [0,1], s/l in [0,1]. | |
| const hslToRgb = (h, s, l) => { | |
| if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; } | |
| const hue2rgb = (p, q, t) => { | |
| if (t < 0) t += 1; | |
| if (t > 1) t -= 1; | |
| if (t < 1 / 6) return p + (q - p) * 6 * t; | |
| if (t < 1 / 2) return q; | |
| if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; | |
| return p; | |
| }; | |
| const q = l < 0.5 ? l * (1 + s) : l + s - l * s; | |
| const p = 2 * l - q; | |
| return [ | |
| Math.round(hue2rgb(p, q, h + 1 / 3) * 255), | |
| Math.round(hue2rgb(p, q, h) * 255), | |
| Math.round(hue2rgb(p, q, h - 1 / 3) * 255), | |
| ]; | |
| }; | |
| // Fable: each letter a different color across the hue wheel. | |
| const rainbow = (text) => { | |
| const chars = [...text]; | |
| return chars | |
| .map((ch, i) => (ch === ' ' ? ch : paint(hslToRgb(chars.length > 1 ? i / chars.length : 0, 1, 0.5), ch))) | |
| .join(''); | |
| }; | |
| const detectFamily = (name, id) => { | |
| const s = `${name} ${id}`.toLowerCase(); | |
| if (s.includes('haiku')) return 'haiku'; | |
| if (s.includes('sonnet')) return 'sonnet'; | |
| if (s.includes('opus')) return 'opus'; | |
| if (s.includes('fable')) return 'fable'; | |
| return null; | |
| }; | |
| const renderModel = (model) => { | |
| const name = model?.display_name || 'Model'; | |
| const family = detectFamily(name, model?.id || ''); | |
| // Short label: family name if recognized, else first token of display name. | |
| const label = family ? family[0].toUpperCase() + family.slice(1) : name.split(/\s+/)[0]; | |
| if (family === 'fable') return rainbow(label); | |
| return paint(config.model[family] || config.model.fallback, label); | |
| }; | |
| // 1000000 -> "1kk", 200000 -> "200k", 8000 -> "8k", else raw. | |
| const fmtSize = (n) => { | |
| if (!Number.isFinite(n) || n <= 0) return '?'; | |
| if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(n % 1_000_000 ? 1 : 0)}kk`; | |
| if (n >= 1000) return `${+(n / 1000).toFixed(n % 1000 ? 1 : 0)}k`; | |
| return `${n}`; | |
| }; | |
| // Rate-limit reset: unix epoch seconds -> "13/17" (day 13, 17:00), local time. | |
| // Minutes appended only when non-zero: "13/17:30". Absent/invalid -> null. | |
| const fmtReset = (epoch) => { | |
| if (!Number.isFinite(epoch) || epoch <= 0) return null; | |
| const dt = new Date(epoch * 1000); | |
| const day = dt.getDate(); | |
| const hh = dt.getHours(); | |
| const mm = dt.getMinutes(); | |
| return mm ? `${day}/${hh}:${String(mm).padStart(2, '0')}` : `${day}/${hh}`; | |
| }; | |
| const shortenCwd = (cwd) => { | |
| if (!cwd) return '?'; | |
| const home = os.homedir(); | |
| let p = cwd; | |
| const sep = p.includes('\\') ? '\\' : '/'; | |
| if (home && (p === home || p.startsWith(home + sep))) p = '~' + p.slice(home.length); | |
| if (p.length <= config.cwdMaxLen) return p; | |
| // Abbreviate every segment except the last to its first char. | |
| const parts = p.split(/[\\/]/); | |
| const last = parts.length - 1; | |
| return parts | |
| .map((seg, i) => { | |
| if (i === last || seg === '' || seg === '~') return seg; | |
| return seg.startsWith('.') ? seg.slice(0, 2) : seg[0]; | |
| }) | |
| .join(sep); | |
| }; | |
| // A percentage cell: "NN%" colored by threshold, then " (label)" muted. | |
| // Missing value (rate limit / usage not yet reported) -> null, so the cell is | |
| // dropped from the line entirely rather than rendered as a dash. | |
| const pctCell = (value, label) => { | |
| if (value === undefined || value === null || Number.isNaN(value)) return null; | |
| const p = Math.round(value); | |
| return paint(pctColor(p), `${p}%`) + paint(config.muted, ` (${label})`); | |
| }; | |
| const readStdin = () => | |
| new Promise((resolve) => { | |
| let data = ''; | |
| process.stdin.setEncoding('utf8'); | |
| process.stdin.on('data', (c) => (data += c)); | |
| process.stdin.on('end', () => resolve(data)); | |
| process.stdin.on('error', () => resolve(data)); | |
| }); | |
| const main = async () => { | |
| let d = {}; | |
| try { | |
| d = JSON.parse((await readStdin()).replace(/^\uFEFF/, '') || '{}'); | |
| } catch { | |
| d = {}; | |
| } | |
| const ctx = d.context_window || {}; | |
| const rl = d.rate_limits || {}; | |
| const cwd = d.workspace?.current_dir || d.cwd; | |
| const sep = paint(config.separator, ' | '); | |
| const sevenDay = rl.seven_day || {}; | |
| const cells = [ | |
| renderModel(d.model), | |
| pctCell(ctx.used_percentage, fmtSize(ctx.context_window_size)), | |
| pctCell(rl.five_hour?.used_percentage, '5h'), | |
| pctCell(sevenDay.used_percentage, fmtReset(sevenDay.resets_at) || '7d'), | |
| paint(config.muted, shortenCwd(cwd)), | |
| ].filter((c) => c != null); | |
| process.stdout.write(cells.join(sep)); | |
| }; | |
| main(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment