mirror of
https://github.com/jparkerweb/plan2code.git
synced 2026-09-17 16:22:23 -07:00
426 lines
13 KiB
JavaScript
426 lines
13 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
|
||
|
|
const fs = require('fs');
|
||
|
|
const os = require('os');
|
||
|
|
const path = require('path');
|
||
|
|
const { execFileSync } = require('child_process');
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Constants
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
const CONTEXT_BAR_LEN = 12;
|
||
|
|
const CONTEXT_THRESHOLDS = { green: 50, yellow: 70 };
|
||
|
|
const RATE_LIMIT_THRESHOLDS = { green: 60, yellow: 80 };
|
||
|
|
const GIT_TIMEOUT_MS = 1500;
|
||
|
|
const DEFAULT_WINDOW_SIZE = 200000;
|
||
|
|
const SEPARATOR_WIDTH = 55;
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Config
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
const DEFAULTS = {
|
||
|
|
autocompactBuffer: 33000,
|
||
|
|
color: true,
|
||
|
|
compact: false,
|
||
|
|
items: {
|
||
|
|
model: true,
|
||
|
|
project: true,
|
||
|
|
branch: true,
|
||
|
|
contextBar: true,
|
||
|
|
planUsage: true,
|
||
|
|
linesChanged: true,
|
||
|
|
duration: true,
|
||
|
|
sessionCost: true,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
function loadConfig() {
|
||
|
|
const configPath = path.join(os.homedir(), '.claude', 'statusline-config.json');
|
||
|
|
let merged = { ...DEFAULTS, items: { ...DEFAULTS.items } };
|
||
|
|
|
||
|
|
try {
|
||
|
|
const raw = fs.readFileSync(configPath, 'utf8');
|
||
|
|
const user = JSON.parse(raw);
|
||
|
|
merged = {
|
||
|
|
...DEFAULTS,
|
||
|
|
...user,
|
||
|
|
items: { ...DEFAULTS.items, ...(user.items || {}) },
|
||
|
|
};
|
||
|
|
} catch {
|
||
|
|
// Fall through to defaults
|
||
|
|
}
|
||
|
|
|
||
|
|
// Type validation with fallback
|
||
|
|
if (!Number.isInteger(merged.autocompactBuffer) || merged.autocompactBuffer < 0 || merged.autocompactBuffer >= 1_000_000) {
|
||
|
|
merged.autocompactBuffer = DEFAULTS.autocompactBuffer;
|
||
|
|
}
|
||
|
|
if (typeof merged.color !== 'boolean') merged.color = DEFAULTS.color;
|
||
|
|
if (typeof merged.compact !== 'boolean') merged.compact = DEFAULTS.compact;
|
||
|
|
for (const key of Object.keys(DEFAULTS.items)) {
|
||
|
|
if (typeof merged.items[key] !== 'boolean') merged.items[key] = DEFAULTS.items[key];
|
||
|
|
}
|
||
|
|
|
||
|
|
// NO_COLOR convention (https://no-color.org) — env trumps config
|
||
|
|
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') {
|
||
|
|
merged.color = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return merged;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Colors
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
const rgb = (r, g, b) => `\x1b[38;2;${r};${g};${b}m`;
|
||
|
|
const C = {
|
||
|
|
reset: '\x1b[0m',
|
||
|
|
bold: '\x1b[1m',
|
||
|
|
dim: '\x1b[2m',
|
||
|
|
green: '\x1b[32m',
|
||
|
|
yellow: '\x1b[33m',
|
||
|
|
red: '\x1b[31m',
|
||
|
|
cyan: '\x1b[36m',
|
||
|
|
white: '\x1b[37m',
|
||
|
|
gray: '\x1b[90m',
|
||
|
|
// Plan2Code brand
|
||
|
|
teal: rgb(190, 192, 200), // Silver — repo
|
||
|
|
sky: rgb(120, 195, 255), // Light sky blue — branch
|
||
|
|
muted: rgb(40, 120, 200), // Mid blue — model
|
||
|
|
label: rgb(130, 135, 150), // Mid gray — field labels (5h, 7d)
|
||
|
|
// Bar & quota thresholds
|
||
|
|
barGreen: rgb(50, 220, 100),
|
||
|
|
barYellow: rgb(220, 180, 50),
|
||
|
|
barRed: rgb(220, 80, 70),
|
||
|
|
// Planny mascot
|
||
|
|
sGreen: rgb(60, 200, 120),
|
||
|
|
sBlue: rgb(50, 100, 200),
|
||
|
|
sEye: rgb(255, 255, 255),
|
||
|
|
};
|
||
|
|
|
||
|
|
const SEP = (config) => config.color ? `${C.gray} │ ${C.reset}` : ' │ ';
|
||
|
|
const DOT = (config) => config.color ? `${C.gray} · ${C.reset}` : ' · ';
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Git helpers
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function gitExec(args, cwd) {
|
||
|
|
try {
|
||
|
|
return execFileSync('git', args, {
|
||
|
|
cwd,
|
||
|
|
timeout: GIT_TIMEOUT_MS,
|
||
|
|
encoding: 'utf8',
|
||
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||
|
|
windowsHide: true,
|
||
|
|
}).trim();
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function isGitRepo(cwd) {
|
||
|
|
try {
|
||
|
|
// Walk up parent dirs (bounded) — monorepos open Claude Code in a subdir
|
||
|
|
// where .git lives at the repo root. .git can be a directory or a file.
|
||
|
|
let dir = cwd;
|
||
|
|
for (let i = 0; i < 10; i++) {
|
||
|
|
if (fs.existsSync(path.join(dir, '.git'))) return true;
|
||
|
|
const parent = path.dirname(dir);
|
||
|
|
if (parent === dir) return false;
|
||
|
|
dir = parent;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
} catch {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function getGitBranch(stdinData) {
|
||
|
|
const cwd = stdinData?.workspace?.project_dir || process.cwd();
|
||
|
|
if (!isGitRepo(cwd)) return '';
|
||
|
|
const branch = gitExec(['symbolic-ref', '--short', 'HEAD'], cwd);
|
||
|
|
if (branch) return branch;
|
||
|
|
// Detached HEAD fallback — show short SHA so branch segment isn't empty
|
||
|
|
const sha = gitExec(['rev-parse', '--short', 'HEAD'], cwd);
|
||
|
|
return sha || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Formatters
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function getModelName(stdinData) {
|
||
|
|
const raw = stdinData?.model;
|
||
|
|
if (!raw) return '';
|
||
|
|
|
||
|
|
if (typeof raw === 'object' && raw.display_name) {
|
||
|
|
return raw.display_name.replace(/ context\)/gi, ')');
|
||
|
|
}
|
||
|
|
|
||
|
|
const model = typeof raw === 'object' ? raw.id : raw;
|
||
|
|
if (!model || typeof model !== 'string') return '';
|
||
|
|
|
||
|
|
const claudeMatch = model.match(/^claude-(\w+)-/);
|
||
|
|
if (claudeMatch) {
|
||
|
|
return claudeMatch[1].charAt(0).toUpperCase() + claudeMatch[1].slice(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const gptMatch = model.match(/^(gpt-[\w.]+)/i);
|
||
|
|
if (gptMatch) {
|
||
|
|
return gptMatch[1].toUpperCase();
|
||
|
|
}
|
||
|
|
|
||
|
|
return model;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getProjectName(stdinData) {
|
||
|
|
const projectDir = stdinData?.workspace?.project_dir;
|
||
|
|
if (!projectDir || typeof projectDir !== 'string') return '';
|
||
|
|
return path.basename(projectDir);
|
||
|
|
}
|
||
|
|
|
||
|
|
function calculateContextPercent(stdinData, config) {
|
||
|
|
const cw = stdinData?.context_window;
|
||
|
|
if (!cw) return null;
|
||
|
|
const windowSize = cw.context_window_size || DEFAULT_WINDOW_SIZE;
|
||
|
|
const buffer = config?.autocompactBuffer ?? DEFAULTS.autocompactBuffer;
|
||
|
|
const usableTokens = windowSize - buffer;
|
||
|
|
if (usableTokens <= 0) return 100;
|
||
|
|
const rawUsedPct = cw.used_percentage ?? 0;
|
||
|
|
const rawUsedTokens = (rawUsedPct / 100) * windowSize;
|
||
|
|
return Math.round(Math.min(100, (rawUsedTokens / usableTokens) * 100));
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatContextBar(percent, config) {
|
||
|
|
if (percent == null) return null;
|
||
|
|
|
||
|
|
const clamped = Math.max(0, Math.min(100, percent));
|
||
|
|
const filled = Math.round((clamped / 100) * CONTEXT_BAR_LEN);
|
||
|
|
const filledStr = '▰'.repeat(filled);
|
||
|
|
const emptyStr = '▱'.repeat(CONTEXT_BAR_LEN - filled);
|
||
|
|
|
||
|
|
if (!config.color) return `${filledStr}${emptyStr} ${clamped}%`;
|
||
|
|
|
||
|
|
const barColor = clamped >= CONTEXT_THRESHOLDS.yellow ? C.barRed
|
||
|
|
: clamped >= CONTEXT_THRESHOLDS.green ? C.barYellow
|
||
|
|
: C.barGreen;
|
||
|
|
return `${barColor}${filledStr}${C.gray}${emptyStr}${C.reset} ${barColor}${clamped}%${C.reset}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatLinesChanged(stdinData, config) {
|
||
|
|
const cwd = stdinData?.workspace?.project_dir || process.cwd();
|
||
|
|
if (!isGitRepo(cwd)) return null;
|
||
|
|
const raw = gitExec(['diff', 'HEAD', '--numstat'], cwd);
|
||
|
|
if (raw == null) return null;
|
||
|
|
|
||
|
|
let added = 0, removed = 0;
|
||
|
|
for (const line of raw.split('\n')) {
|
||
|
|
if (!line) continue;
|
||
|
|
const [a, r] = line.split('\t');
|
||
|
|
if (a !== '-') added += parseInt(a, 10) || 0;
|
||
|
|
if (r !== '-') removed += parseInt(r, 10) || 0;
|
||
|
|
}
|
||
|
|
if (added === 0 && removed === 0) return null;
|
||
|
|
if (!config.color) return `+${added} -${removed}`;
|
||
|
|
return `${C.green}+${added}${C.reset} ${C.red}-${removed}${C.reset}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatSessionCost(stdinData) {
|
||
|
|
const usd = stdinData?.cost?.total_cost_usd;
|
||
|
|
if (usd == null || isNaN(usd) || usd <= 0) return null;
|
||
|
|
if (usd < 0.01) return '<$0.01';
|
||
|
|
if (usd < 100) return `$${usd.toFixed(2)}`;
|
||
|
|
return `$${Math.round(usd)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatDuration(stdinData, config) {
|
||
|
|
const ms = stdinData?.cost?.total_duration_ms;
|
||
|
|
if (!ms) return null;
|
||
|
|
|
||
|
|
const totalSec = Math.round(ms / 1000);
|
||
|
|
let text;
|
||
|
|
if (totalSec < 60) text = `${totalSec}s`;
|
||
|
|
else {
|
||
|
|
const hours = Math.floor(totalSec / 3600);
|
||
|
|
const mins = Math.floor((totalSec % 3600) / 60);
|
||
|
|
text = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Append session cost in parens when enabled: "49m ($4.62)"
|
||
|
|
if (config.items.sessionCost) {
|
||
|
|
const cost = formatSessionCost(stdinData);
|
||
|
|
if (cost) {
|
||
|
|
text = config.color
|
||
|
|
? `${C.label}${text} (${cost})${C.reset}`
|
||
|
|
: `${text} (${cost})`;
|
||
|
|
return text;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return config.color ? `${C.label}${text}${C.reset}` : text;
|
||
|
|
}
|
||
|
|
|
||
|
|
function colorize(text, percent, thresholds, config) {
|
||
|
|
if (!config.color || percent == null) return text;
|
||
|
|
let code;
|
||
|
|
if (percent >= thresholds.yellow) code = C.barRed;
|
||
|
|
else if (percent >= thresholds.green) code = C.barYellow;
|
||
|
|
else code = C.barGreen;
|
||
|
|
return `${code}${text}${C.reset}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTokenCount(n) {
|
||
|
|
if (n == null || isNaN(n) || n < 0) return '0';
|
||
|
|
if (n < 1000) return String(Math.round(n));
|
||
|
|
if (n < 999500) return Math.round(n / 1000) + 'k';
|
||
|
|
return (n / 1_000_000).toFixed(1) + 'M';
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatRateLimits(stdinData, config) {
|
||
|
|
if (!config.items.planUsage) return null;
|
||
|
|
const fiveHour = stdinData?.rate_limits?.five_hour?.used_percentage;
|
||
|
|
const sevenDay = stdinData?.rate_limits?.seven_day?.used_percentage;
|
||
|
|
if (fiveHour == null && sevenDay == null) return null;
|
||
|
|
|
||
|
|
const parts = [];
|
||
|
|
if (fiveHour != null) {
|
||
|
|
const pct = Math.round(fiveHour);
|
||
|
|
const val = colorize(`${pct}%`, pct, RATE_LIMIT_THRESHOLDS, config);
|
||
|
|
parts.push(config.color ? `${C.label}5h:${C.reset} ${val}` : `5h: ${pct}%`);
|
||
|
|
}
|
||
|
|
if (sevenDay != null) {
|
||
|
|
const pct = Math.round(sevenDay);
|
||
|
|
const val = colorize(`${pct}%`, pct, RATE_LIMIT_THRESHOLDS, config);
|
||
|
|
parts.push(config.color ? `${C.label}7d:${C.reset} ${val}` : `7d: ${pct}%`);
|
||
|
|
}
|
||
|
|
return parts.join(DOT(config));
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatTokenUsage(stdinData, config) {
|
||
|
|
if (!config.items.planUsage) return null;
|
||
|
|
const inp = stdinData?.context_window?.total_input_tokens;
|
||
|
|
const out = stdinData?.context_window?.total_output_tokens;
|
||
|
|
if ((!inp || inp === 0) && (!out || out === 0)) return null;
|
||
|
|
const inStr = formatTokenCount(inp || 0);
|
||
|
|
const outStr = formatTokenCount(out || 0);
|
||
|
|
if (!config.color) return `${inStr} in${DOT(config)}${outStr} out`;
|
||
|
|
return `${C.white}${inStr}${C.reset} ${C.label}in${C.reset}${DOT(config)}${C.white}${outStr}${C.reset} ${C.label}out${C.reset}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatLine1(stdinData, config) {
|
||
|
|
const segments = [];
|
||
|
|
|
||
|
|
if (config.items.model) {
|
||
|
|
const model = getModelName(stdinData);
|
||
|
|
if (model) segments.push(config.color ? `${C.muted}${model}${C.reset}` : model);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.items.project) {
|
||
|
|
const project = getProjectName(stdinData);
|
||
|
|
if (project) segments.push(config.color ? `${C.bold}${C.teal}${project}${C.reset}` : project);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.items.branch) {
|
||
|
|
const branch = getGitBranch(stdinData);
|
||
|
|
if (branch) segments.push(config.color ? `${C.sky}${branch}${C.reset}` : branch);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.items.linesChanged) {
|
||
|
|
const lines = formatLinesChanged(stdinData, config);
|
||
|
|
if (lines) segments.push(lines);
|
||
|
|
}
|
||
|
|
|
||
|
|
return segments.join(SEP(config));
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatLine2(stdinData, config) {
|
||
|
|
const segments = [];
|
||
|
|
|
||
|
|
if (config.items.duration) {
|
||
|
|
const dur = formatDuration(stdinData, config);
|
||
|
|
if (dur) segments.push(dur);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.items.contextBar) {
|
||
|
|
const percent = calculateContextPercent(stdinData, config);
|
||
|
|
const bar = formatContextBar(percent, config);
|
||
|
|
if (bar) segments.push(bar);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.items.planUsage) {
|
||
|
|
const usage = formatRateLimits(stdinData, config) || formatTokenUsage(stdinData, config);
|
||
|
|
if (usage) segments.push(usage);
|
||
|
|
}
|
||
|
|
|
||
|
|
return segments.join(SEP(config));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================================================
|
||
|
|
// Main
|
||
|
|
// ============================================================================
|
||
|
|
|
||
|
|
function loadStdin() {
|
||
|
|
// --fixture <path> for local diagnostics (bypasses stdin)
|
||
|
|
const fixtureIdx = process.argv.indexOf('--fixture');
|
||
|
|
if (fixtureIdx !== -1 && process.argv[fixtureIdx + 1]) {
|
||
|
|
try {
|
||
|
|
return JSON.parse(fs.readFileSync(process.argv[fixtureIdx + 1], 'utf8'));
|
||
|
|
} catch (err) {
|
||
|
|
process.stderr.write(`[statusline] fixture load failed: ${err.message}\n`);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
return JSON.parse(fs.readFileSync(0, 'utf8'));
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const debug = process.argv.includes('--debug');
|
||
|
|
const stdinData = loadStdin();
|
||
|
|
if (!stdinData) process.exit(0);
|
||
|
|
|
||
|
|
const config = loadConfig();
|
||
|
|
|
||
|
|
if (debug) {
|
||
|
|
process.stderr.write('[statusline debug] parsed stdin:\n');
|
||
|
|
process.stderr.write(JSON.stringify(stdinData, null, 2) + '\n');
|
||
|
|
process.stderr.write('[statusline debug] merged config:\n');
|
||
|
|
process.stderr.write(JSON.stringify(config, null, 2) + '\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
const line1 = formatLine1(stdinData, config);
|
||
|
|
const line2 = formatLine2(stdinData, config);
|
||
|
|
if (!line1 && !line2) return;
|
||
|
|
|
||
|
|
if (config.compact) {
|
||
|
|
if (line1) process.stdout.write(line1 + '\n');
|
||
|
|
if (line2) process.stdout.write(line2 + '\n');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (config.color) {
|
||
|
|
const ico1 = `${C.sGreen}╭─╮${C.reset} `;
|
||
|
|
const ico2 = `${C.sGreen}│${C.sEye}★${C.sGreen}│${C.reset} `;
|
||
|
|
const ico3 = `${C.sGreen}╰─╯${C.reset} `;
|
||
|
|
const pad = `${C.gray}${'┄'.repeat(SEPARATOR_WIDTH)}${C.reset}`;
|
||
|
|
process.stdout.write(ico1 + line1 + '\n' + ico2 + pad + '\n' + ico3 + line2 + '\n');
|
||
|
|
} else {
|
||
|
|
const ico1 = '╭─╮ ';
|
||
|
|
const ico2 = '│★│ ';
|
||
|
|
const ico3 = '╰─╯ ';
|
||
|
|
const pad = '┄'.repeat(SEPARATOR_WIDTH);
|
||
|
|
process.stdout.write(ico1 + line1 + '\n' + ico2 + pad + '\n' + ico3 + line2 + '\n');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
main();
|
||
|
|
} catch {
|
||
|
|
process.exit(0);
|
||
|
|
}
|