Files
ECC/scripts/hooks/ecc-statusline.js
T

247 lines
8.0 KiB
JavaScript

#!/usr/bin/env node
/**
* ECC Statusline — statusLine command
*
* Displays: model | task | $cost Nt Nf Nm | dir ██░░ N%
*
* Registered in settings.json under "statusLine", not in hooks.json.
* Reads bridge file from ecc-metrics-bridge.js and stdin from Claude Code runtime.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
const AUTO_COMPACT_BUFFER_PCT = 16.5;
const MAX_STDIN = 1024 * 1024;
/**
* Format duration from ISO timestamp to now.
* @param {string} isoTimestamp
* @returns {string} e.g. "5s", "12m", "1h23m"
*/
function formatDuration(isoTimestamp) {
if (!isoTimestamp) return '?';
const elapsed = Math.floor((Date.now() - new Date(isoTimestamp).getTime()) / 1000);
if (elapsed < 0) return '?';
if (elapsed < 60) return `${elapsed}s`;
const mins = Math.floor(elapsed / 60);
if (mins < 60) return `${mins}m`;
const hours = Math.floor(mins / 60);
const remMins = mins % 60;
return remMins > 0 ? `${hours}h${remMins}m` : `${hours}h`;
}
/**
* Build context progress bar with ANSI colors.
* @param {number} remaining - Raw remaining percentage from Claude Code
* @returns {string} Colored bar string
*/
function buildContextBar(remaining, glyphs) {
if (remaining === null || remaining === undefined) return '';
const usableRemaining = Math.max(0, ((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
const filled = Math.floor(used / 10);
const bar = (glyphs?.full || '\u2588').repeat(filled)
+ (glyphs?.empty || '\u2591').repeat(10 - filled);
if (used < 50) return ` \x1b[32m${bar} ${used}%\x1b[0m`;
if (used < 65) return ` \x1b[33m${bar} ${used}%\x1b[0m`;
if (used < 80) return ` \x1b[38;5;208m${bar} ${used}%\x1b[0m`;
return ` \x1b[1;31m${bar} ${used}%\x1b[0m`;
}
/**
* Read current in-progress task from todos directory.
* @param {string} sessionId
* @returns {string} Task activeForm text or empty string
*/
function readCurrentTask(sessionId) {
try {
const safeSessionId = sanitizeSessionId(sessionId);
if (!safeSessionId) return '';
const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
const todosDir = path.join(claudeDir, 'todos');
if (!fs.existsSync(todosDir)) return '';
const files = fs
.readdirSync(todosDir)
.filter(f => f.startsWith(safeSessionId) && f.includes('-agent-') && f.endsWith('.json'))
.map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
.sort((a, b) => b.mtime - a.mtime);
if (files.length === 0) return '';
const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
const inProgress = todos.find(t => t.status === 'in_progress');
return inProgress?.activeForm || '';
} catch {
return '';
}
}
function getEccVersion(plugins) {
const ecc = plugins.find(p => p.name === 'ecc');
if (ecc?.version) return ecc.version;
try {
return fs.readFileSync(path.join(__dirname, '..', '..', 'VERSION'), 'utf8').trim();
} catch {
return '';
}
}
function sessionDurationMs(cost, bridge) {
if (Number.isFinite(cost.total_duration_ms) && cost.total_duration_ms >= 0) {
return cost.total_duration_ms;
}
const elapsed = bridge?.first_timestamp
? Date.now() - new Date(bridge.first_timestamp).getTime()
: 0;
return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : 0;
}
/**
* Assemble statusline output lines from Claude Code stdin data.
* @param {object} data - Parsed statusLine stdin JSON
* @param {object|null} bridge - Session bridge metrics or null
* @param {string} task - Current in-progress task
* @returns {string[]} Non-empty output lines
*/
function buildUsageLines(data, bridge, task) {
const render = require('../lib/statusline-render');
const remaining = data.context_window?.remaining_percentage;
const cost = data.cost || {};
const dirname = path.basename(data.workspace?.current_dir || process.cwd());
if (process.env.ECC_STATUSLINE_COMPACT === '1') {
const segments = [`\x1b[2m${data.model?.display_name || 'Claude'}\x1b[0m`];
if (task) segments.push(`\x1b[1;97m${task}\x1b[0m`);
const usage = render.buildUsageLine(data.rate_limits, null, { compact: true });
if (usage) segments.push(usage);
segments.push(`\x1b[2m${dirname}\x1b[0m`);
return [segments.join(render.SEP) + buildContextBar(remaining, render.G)];
}
const usageLine = render.buildUsageLine(
data.rate_limits,
render.computeCacheStats(data.context_window?.current_usage)
);
const durationMs = sessionDurationMs(cost, bridge);
const sessionLine = render.buildSessionLine({
model: data.model?.display_name,
effort: data.effort?.level,
fastMode: data.fast_mode === true,
ctxBar: buildContextBar(remaining, render.G),
ctxWindowSize: data.context_window?.context_window_size,
costUsd: cost.total_cost_usd ?? bridge?.total_cost_usd ?? 0,
linesAdded: cost.total_lines_added || 0,
linesRemoved: cost.total_lines_removed || 0,
durationMs,
task,
});
const plugins = render.readInstalledPlugins(undefined, data.workspace?.current_dir || process.cwd());
const eccLine = render.buildEccLine({
eccVersion: getEccVersion(plugins),
hooks: render.getHooksSummary(),
plugins,
dirname,
});
return [usageLine, sessionLine, eccLine].filter(Boolean);
}
function runStatusline() {
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (input.length < MAX_STDIN) {
input += chunk.substring(0, MAX_STDIN - input.length);
}
});
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input);
const model = data.model?.display_name || 'Claude';
const dir = data.workspace?.current_dir || process.cwd();
const session = data.session_id || '';
const remaining = data.context_window?.remaining_percentage;
const sessionId = sanitizeSessionId(session);
const bridge = sessionId ? readBridge(sessionId) : null;
// Write context % back to bridge for context-monitor
if (sessionId && bridge && remaining !== null && remaining !== undefined) {
bridge.context_remaining_pct = remaining;
try {
writeBridgeAtomic(sessionId, bridge);
} catch {
/* best effort */
}
}
// Current task
const task = sessionId ? readCurrentTask(sessionId) : '';
if (process.env.ECC_STATUSLINE_USAGE === '1') {
process.stdout.write(buildUsageLines(data, bridge, task).join('\n'));
return;
}
// Metrics from bridge
let metricsStr = '';
if (bridge) {
const parts = [];
if (bridge.total_cost_usd > 0) {
parts.push(`$${bridge.total_cost_usd.toFixed(2)}`);
}
if (bridge.tool_count > 0) {
parts.push(`${bridge.tool_count}t`);
}
if (bridge.files_modified_count > 0) {
parts.push(`${bridge.files_modified_count}f`);
}
const dur = formatDuration(bridge.first_timestamp);
if (dur !== '?') {
parts.push(dur);
}
if (parts.length > 0) {
metricsStr = `\x1b[38;5;117m${parts.join(' ')}\x1b[0m`;
}
}
// Context bar
const ctx = buildContextBar(remaining);
// Build output
const dirname = path.basename(dir);
const segments = [`\x1b[2m${model}\x1b[0m`];
if (task) {
segments.push(`\x1b[1;97m${task}\x1b[0m`);
}
if (metricsStr) {
segments.push(metricsStr);
}
segments.push(`\x1b[2m${dirname}\x1b[0m`);
process.stdout.write(segments.join(' \x1b[2m\u2502\x1b[0m ') + ctx);
} catch {
// Silent fail
}
});
}
module.exports = { formatDuration, buildContextBar, readCurrentTask, buildUsageLines, MAX_STDIN };
if (require.main === module) runStatusline();