#!/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) { 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 = '\u2588'.repeat(filled) + '\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[5;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 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) : ''; // 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[36m${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[1m${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, MAX_STDIN }; if (require.main === module) runStatusline();