diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js new file mode 100644 index 00000000..c4b35f85 --- /dev/null +++ b/scripts/hooks/ecc-statusline.js @@ -0,0 +1,160 @@ +#!/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; + +/** + * 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) 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 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(sessionId) && 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 => (input += chunk)); + 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) { + bridge.context_remaining_pct = remaining; + try { + writeBridgeAtomic(sessionId, bridge); + } catch { + /* best effort */ + } + } + + // Current task + const task = session ? readCurrentTask(session) : ''; + + // 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 }; + +if (require.main === module) runStatusline(); diff --git a/tests/hooks/ecc-statusline.test.js b/tests/hooks/ecc-statusline.test.js new file mode 100644 index 00000000..fee5a4a3 --- /dev/null +++ b/tests/hooks/ecc-statusline.test.js @@ -0,0 +1,180 @@ +/** + * Tests for scripts/hooks/ecc-statusline.js + * + * Run with: node tests/hooks/ecc-statusline.test.js + */ + +const assert = require('assert'); + +const { formatDuration, buildContextBar, readCurrentTask } = require('../../scripts/hooks/ecc-statusline'); + +// Test helper +function test(name, fn) { + try { + fn(); + console.log(` \u2713 ${name}`); + return true; + } catch (err) { + console.log(` \u2717 ${name}`); + console.log(` Error: ${err.message}`); + return false; + } +} + +function runTests() { + console.log('\n=== Testing ecc-statusline.js ===\n'); + + let passed = 0; + let failed = 0; + + // formatDuration tests + console.log('formatDuration:'); + + if ( + test('null returns "?"', () => { + assert.strictEqual(formatDuration(null), '?'); + }) + ) + passed++; + else failed++; + + if ( + test('undefined returns "?"', () => { + assert.strictEqual(formatDuration(undefined), '?'); + }) + ) + passed++; + else failed++; + + if ( + test('timestamp 30 seconds ago ends with "s"', () => { + const ts = new Date(Date.now() - 30 * 1000).toISOString(); + const result = formatDuration(ts); + assert.ok(result.endsWith('s'), `Expected ending in "s", got: ${result}`); + }) + ) + passed++; + else failed++; + + if ( + test('timestamp 5 minutes ago ends with "m"', () => { + const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + const result = formatDuration(ts); + assert.ok(result.endsWith('m'), `Expected ending in "m", got: ${result}`); + }) + ) + passed++; + else failed++; + + if ( + test('timestamp 90 minutes ago contains "h"', () => { + const ts = new Date(Date.now() - 90 * 60 * 1000).toISOString(); + const result = formatDuration(ts); + assert.ok(result.includes('h'), `Expected "h" in result, got: ${result}`); + }) + ) + passed++; + else failed++; + + if ( + test('future timestamp returns "?"', () => { + const ts = new Date(Date.now() + 60 * 1000).toISOString(); + const result = formatDuration(ts); + assert.strictEqual(result, '?'); + }) + ) + passed++; + else failed++; + + // buildContextBar tests + console.log('\nbuildContextBar:'); + + if ( + test('null returns empty string', () => { + assert.strictEqual(buildContextBar(null), ''); + }) + ) + passed++; + else failed++; + + if ( + test('undefined returns empty string', () => { + assert.strictEqual(buildContextBar(undefined), ''); + }) + ) + passed++; + else failed++; + + if ( + test('80% remaining contains green ANSI code', () => { + const bar = buildContextBar(80); + assert.ok(bar.includes('\x1b[32m'), `Expected green ANSI in: ${JSON.stringify(bar)}`); + }) + ) + passed++; + else failed++; + + if ( + test('50% remaining contains yellow ANSI code', () => { + const bar = buildContextBar(50); + assert.ok(bar.includes('\x1b[33m'), `Expected yellow ANSI in: ${JSON.stringify(bar)}`); + }) + ) + passed++; + else failed++; + + if ( + test('20% remaining contains red blink ANSI code', () => { + const bar = buildContextBar(20); + assert.ok(bar.includes('\x1b[5;31m'), `Expected red blink ANSI in: ${JSON.stringify(bar)}`); + }) + ) + passed++; + else failed++; + + if ( + test('context bar contains block characters', () => { + const bar = buildContextBar(60); + assert.ok(bar.includes('\u2588') || bar.includes('\u2591'), 'Expected block characters in bar'); + }) + ) + passed++; + else failed++; + + if ( + test('context bar contains percentage', () => { + const bar = buildContextBar(70); + assert.ok(bar.includes('%'), 'Expected percentage in bar'); + }) + ) + passed++; + else failed++; + + // readCurrentTask tests + console.log('\nreadCurrentTask:'); + + if ( + test('nonexistent session returns empty string', () => { + const result = readCurrentTask('nonexistent-session-xyz-999'); + assert.strictEqual(result, ''); + }) + ) + passed++; + else failed++; + + if ( + test('empty string session returns empty string', () => { + const result = readCurrentTask(''); + assert.strictEqual(result, ''); + }) + ) + passed++; + else failed++; + + // Summary + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0);