From 3add1816ea2189d9c7375df05c16c85cd3ddc260 Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Sun, 11 Oct 2026 02:09:06 -0400 Subject: [PATCH] feat: offer an opt-in local Claude usage display Scoped adaptation of PR #2716: keep compact rendering as the default and require ECC_STATUSLINE_USAGE=1. Installer registration, managed aliases and the Codex background bar remain outside this slice. --- docs/STATUSLINE.md | 32 +++ scripts/hooks/ecc-statusline.js | 69 +++++- scripts/lib/statusline-render.js | 278 ++++++++++++++++++++++ tests/hooks/ecc-statusline-usage.test.js | 31 +++ tests/lib/statusline-render.test.js | 288 +++++++++++++++++++++++ 5 files changed, 697 insertions(+), 1 deletion(-) create mode 100644 docs/STATUSLINE.md create mode 100644 scripts/lib/statusline-render.js create mode 100644 tests/hooks/ecc-statusline-usage.test.js create mode 100644 tests/lib/statusline-render.test.js diff --git a/docs/STATUSLINE.md b/docs/STATUSLINE.md new file mode 100644 index 000000000..86610904a --- /dev/null +++ b/docs/STATUSLINE.md @@ -0,0 +1,32 @@ +# Optional Claude usage display + +ECC's existing statusline can show rate-limit windows, prompt-cache usage, +session cost, and installed plugin information when explicitly enabled. It +reads Claude's statusline input and local ECC metrics; it makes no network +requests and does not register hooks or change shell aliases. + +To try it on macOS or Linux, adapt the existing statusline example in +`examples/statusline.json` and prefix its command with `ECC_STATUSLINE_USAGE=1`: + +```json +{ + "statusLine": { + "type": "command", + "command": "ECC_STATUSLINE_USAGE=1 /absolute/path/to/node /absolute/path/to/ecc/scripts/hooks/ecc-statusline.js" + } +} +``` + +Use the absolute Node executable that works on your machine, and quote paths +containing spaces. Keep any existing custom statusline unless you choose to +replace it. Windows users can set `ECC_STATUSLINE_USAGE=1` in their provider's +environment and use the same script command with quoted absolute paths. + +Removing the environment setting restores the existing compact output. Missing +rate-limit data produces no usage window. `ECC_BAR_GLYPHS=ascii` selects ASCII +symbols, and `ECC_STATUSLINE_COMPACT=1` requests the compact variant inside +the optional display. + +The rendering idea comes from PR #2716 and the MIT-licensed +[claude-code-usage-bar](https://github.com/leeguooooo/claude-code-usage-bar). +Automatic installation and a managed Codex terminal bar remain separate work. diff --git a/scripts/hooks/ecc-statusline.js b/scripts/hooks/ecc-statusline.js index 413dbda49..11006a354 100644 --- a/scripts/hooks/ecc-statusline.js +++ b/scripts/hooks/ecc-statusline.js @@ -85,6 +85,68 @@ function readCurrentTask(sessionId) { } } +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 ''; + } +} + +/** + * 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`); + segments.push(`\x1b[2m${dirname}\x1b[0m`); + return [segments.join(render.SEP) + buildContextBar(remaining)]; + } + + const usageLine = render.buildUsageLine( + data.rate_limits, + render.computeCacheStats(data.context_window?.current_usage) + ); + + const durationMs = bridge?.first_timestamp + ? Date.now() - new Date(bridge.first_timestamp).getTime() + : 0; + const sessionLine = render.buildSessionLine({ + model: data.model?.display_name, + effort: data.effort?.level, + fastMode: data.fast_mode === true, + ctxBar: buildContextBar(remaining), + 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(); + 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); @@ -119,6 +181,11 @@ function runStatusline() { // 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) { @@ -163,6 +230,6 @@ function runStatusline() { }); } -module.exports = { formatDuration, buildContextBar, readCurrentTask, MAX_STDIN }; +module.exports = { formatDuration, buildContextBar, readCurrentTask, buildUsageLines, MAX_STDIN }; if (require.main === module) runStatusline(); diff --git a/scripts/lib/statusline-render.js b/scripts/lib/statusline-render.js new file mode 100644 index 000000000..00abcde04 --- /dev/null +++ b/scripts/lib/statusline-render.js @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Shared rendering helpers for the ECC usage bar statusline. + * + * Inspired by leeguooooo/claude-code-usage-bar (MIT). ECC re-implementation + * with brand colors, hooks/plugin awareness, and no external dependencies. + * + * Colors use 256-color ANSI so macOS Terminal.app renders them correctly. + * Brand palette (from assets/ecc-icon.svg): amber #F59E0B ~ 214, + * terracotta #E07856 ~ 173. + */ + +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { areHooksEnabled, getHookProfile, getDisabledHookIds } = require('./hook-flags'); + +const C = { + amber: '\x1b[38;5;214m', + terracotta: '\x1b[38;5;173m', + yellow: '\x1b[38;5;220m', + orange: '\x1b[38;5;208m', + red: '\x1b[1;38;5;196m', + green: '\x1b[38;5;114m', + dim: '\x1b[2m', + reset: '\x1b[0m', +}; + +const UNICODE_GLYPHS = { + full: '\u2588', empty: '\u2591', sep: '\u2502', bolt: '\u26a1', star: '\u2733', hex: '\u2b22', reset: '\u21bb', dot: '\u00b7', +}; +const ASCII_GLYPHS = { + full: '|', empty: '_', sep: '|', bolt: '*', star: '*', hex: '#', reset: '~', dot: '-', +}; + +/** + * Glyphs degrade to ASCII when the environment cannot be trusted to render + * block/box characters: tmux mangles them to underscores without a UTF-8 + * locale, and fonts such as Terminal.app's default lack \u2b22/\u2733. + * Force either set with ECC_BAR_GLYPHS=ascii|unicode. + */ +function glyphSet(env = process.env) { + const forced = String(env.ECC_BAR_GLYPHS || '').trim().toLowerCase(); + if (forced === 'ascii') return ASCII_GLYPHS; + if (forced === 'unicode') return UNICODE_GLYPHS; + const locale = `${env.LC_ALL || ''} ${env.LC_CTYPE || ''} ${env.LANG || ''}`.toLowerCase(); + return locale.includes('utf') ? UNICODE_GLYPHS : ASCII_GLYPHS; +} + +const G = glyphSet(); +const SEP = ` ${C.dim}${G.sep}${C.reset} `; + +/** + * Severity color for a usage percentage: brand color while healthy, + * warm ramp as the window fills up. + * @param {number} pct - 0..100 used + * @param {string} brandColor - color to use below 60% + */ +function pctColor(pct, brandColor = C.amber) { + if (pct >= 90) return C.red; + if (pct >= 80) return C.orange; + if (pct >= 60) return C.yellow; + return brandColor; +} + +/** + * Block bar for a percentage. Uses the same glyphs as the context bar. + * @param {number} pct - 0..100 used + * @param {number} width - bar width in cells + */ +function buildBar(pct, width = 8) { + const clamped = Math.max(0, Math.min(100, Math.round(pct))); + const filled = Math.round((clamped / 100) * width); + return G.full.repeat(filled) + G.empty.repeat(width - filled); +} + +/** + * Countdown until a unix-seconds timestamp: "42m", "1h42m", "3d". + * @param {number} epochSecs + * @returns {string} countdown or '' when invalid/past + */ +function formatCountdown(epochSecs) { + if (!epochSecs || typeof epochSecs !== 'number') return ''; + const secs = Math.floor(epochSecs - Date.now() / 1000); + if (secs <= 0) return ''; + const mins = Math.ceil(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) { + const rem = mins % 60; + return rem > 0 ? `${hours}h${rem}m` : `${hours}h`; + } + return `${Math.round(hours / 24)}d`; +} + +/** + * Wall-clock duration from milliseconds: "5s", "12m", "3h11m". + * @param {number} ms + */ +function formatMs(ms) { + if (!ms || ms < 0) return ''; + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + const rem = mins % 60; + return rem > 0 ? `${hours}h${rem}m` : `${hours}h`; +} + +/** + * Prompt-cache stats from context_window.current_usage. + * @returns {{hitPct: number}|null} null when there is no usage data yet + */ +function computeCacheStats(currentUsage) { + if (!currentUsage || typeof currentUsage !== 'object') return null; + const read = currentUsage.cache_read_input_tokens || 0; + const write = currentUsage.cache_creation_input_tokens || 0; + const fresh = currentUsage.input_tokens || 0; + const total = read + write + fresh; + if (total <= 0) return null; + return { hitPct: Math.round((read / total) * 100) }; +} + +function usageSegment(label, labelColor, window) { + const pct = Math.round(window?.used_percentage ?? -1); + if (pct < 0) return ''; + const color = pctColor(pct, labelColor); + const reset = formatCountdown(window.resets_at); + const resetStr = reset ? ` ${C.dim}${G.reset}${reset}${C.reset}` : ''; + return `${labelColor}${label}${C.reset} ${color}${buildBar(pct)} ${pct}%${C.reset}${resetStr}`; +} + +/** + * Line 1: rate-limit windows + prompt cache. + * Returns '' when no rate limit data is available (e.g. API-key billing). + */ +function buildUsageLine(rateLimits, cacheStats) { + const parts = []; + const fiveHour = usageSegment('5h', C.amber, rateLimits?.five_hour); + const sevenDay = usageSegment('7d', C.terracotta, rateLimits?.seven_day); + if (fiveHour) parts.push(fiveHour); + if (sevenDay) parts.push(sevenDay); + if (cacheStats && parts.length > 0) { + const cacheColor = cacheStats.hitPct >= 50 ? C.green : C.dim; + parts.push(`${C.dim}cache${C.reset} ${cacheColor}${cacheStats.hitPct}%${C.reset}`); + } + if (parts.length === 0) return ''; + return `${C.amber}${G.bolt}${C.reset} ${parts.join(SEP)}`; +} + +/** + * Line 2: model, mode, context bar, cost, diff, duration, current task. + */ +function buildSessionLine(info) { + const parts = []; + let model = `${C.amber}${G.star} ${info.model || 'Claude'}${C.reset}`; + const badges = []; + if (info.effort && info.effort !== 'high') badges.push(info.effort); + if (info.fastMode) badges.push('fast'); + if (badges.length > 0) model += ` ${C.dim}${badges.join(' ')}${C.reset}`; + parts.push(model); + + if (info.ctxBar) { + const size = info.ctxWindowSize >= 1000000 + ? '1M' + : info.ctxWindowSize ? `${Math.round(info.ctxWindowSize / 1000)}K` : ''; + const sizeStr = size ? ` ${C.dim}${size}${C.reset}` : ''; + parts.push(`${C.dim}ctx${C.reset}${info.ctxBar}${sizeStr}`); + } + + const metrics = []; + if (info.costUsd > 0) metrics.push(`$${info.costUsd.toFixed(2)}`); + if (info.linesAdded > 0 || info.linesRemoved > 0) { + metrics.push(`+${info.linesAdded || 0}/-${info.linesRemoved || 0}`); + } + const dur = formatMs(info.durationMs); + if (dur) metrics.push(dur); + if (metrics.length > 0) parts.push(`\x1b[38;5;117m${metrics.join(' ')}${C.reset}`); + + if (info.task) parts.push(`\x1b[1;97m${info.task}${C.reset}`); + return parts.join(SEP); +} + +/** + * Line 3: ECC version, hooks status, enabled plugins with versions, directory. + */ +function buildEccLine(info) { + const parts = []; + const version = info.eccVersion ? ` ${info.eccVersion}` : ''; + parts.push(`${C.terracotta}${G.hex} ECC${version}${C.reset}`); + + if (info.hooks) { + const h = info.hooks; + let hookStr; + if (!h.enabled) hookStr = `${C.red}hooks off${C.reset}`; + else { + const off = h.disabledCount > 0 ? ` ${C.dim}(${h.disabledCount} off)${C.reset}` : ''; + hookStr = `${C.green}hooks ${h.profile}${C.reset}${off}`; + } + parts.push(hookStr); + } + + if (Array.isArray(info.plugins) && info.plugins.length > 0) { + const shown = info.plugins.slice(0, 4) + .map(p => `${p.name}${p.version ? ` ${C.dim}${p.version}${C.reset}` : ''}`); + const more = info.plugins.length > 4 ? ` ${C.dim}+${info.plugins.length - 4}${C.reset}` : ''; + parts.push(`${C.dim}plugins${C.reset} ${shown.join(` ${G.dot} `)}${more}`); + } + + if (info.dirname) parts.push(`${C.dim}${info.dirname}${C.reset}`); + return parts.join(SEP); +} + +/** + * Enabled plugins with versions, ecc first. + * Reads installed_plugins.json (v2) and settings.json enabledPlugins. + * @param {string} [claudeDir] - defaults to CLAUDE_CONFIG_DIR or ~/.claude + * @returns {Array<{name: string, version: string}>} + */ +function readInstalledPlugins(claudeDir) { + try { + const dir = claudeDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'); + const installed = JSON.parse( + fs.readFileSync(path.join(dir, 'plugins', 'installed_plugins.json'), 'utf8') + ); + let enabled = null; + try { + const settings = JSON.parse(fs.readFileSync(path.join(dir, 'settings.json'), 'utf8')); + if (settings.enabledPlugins && typeof settings.enabledPlugins === 'object') { + enabled = settings.enabledPlugins; + } + } catch { /* no settings \u2014 show all installed */ } + + const plugins = []; + for (const [id, entries] of Object.entries(installed?.plugins || {})) { + if (enabled && enabled[id] === false) continue; + const entry = Array.isArray(entries) ? entries[0] : entries; + plugins.push({ name: id.split('@')[0], version: entry?.version || '' }); + } + plugins.sort((a, b) => (a.name === 'ecc' ? -1 : b.name === 'ecc' ? 1 : a.name.localeCompare(b.name))); + return plugins; + } catch { + return []; + } +} + +/** + * Hooks status summary from ECC hook flags (env + managed config). + */ +function getHooksSummary(env = process.env) { + return { + enabled: areHooksEnabled(env), + profile: getHookProfile(env), + disabledCount: getDisabledHookIds(env).size, + }; +} + +module.exports = { + C, + G, + SEP, + UNICODE_GLYPHS, + ASCII_GLYPHS, + glyphSet, + pctColor, + buildBar, + formatCountdown, + formatMs, + computeCacheStats, + buildUsageLine, + buildSessionLine, + buildEccLine, + readInstalledPlugins, + getHooksSummary, +}; diff --git a/tests/hooks/ecc-statusline-usage.test.js b/tests/hooks/ecc-statusline-usage.test.js new file mode 100644 index 000000000..5d045233c --- /dev/null +++ b/tests/hooks/ecc-statusline-usage.test.js @@ -0,0 +1,31 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); + +test('actual statusline usage display is opt-in and leaves provider settings alone', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-statusline-usage-')); + try { + const payload = { model: { display_name: 'Claude fixture' }, context_window: { remaining_percentage: 80 }, + rate_limits: { five_hour: { used_percentage: 20, resets_at: Math.floor(Date.now() / 1000) + 3600 } } }; + const render = value => { + const child = spawnSync(process.execPath, [path.resolve(__dirname, '../../scripts/hooks/ecc-statusline.js')], { + input: JSON.stringify(payload), encoding: 'utf8', timeout: 5000, + env: { ...process.env, HOME: home, USERPROFILE: home, CLAUDE_CONFIG_DIR: home, + ECC_STATUSLINE_USAGE: value, ECC_BAR_GLYPHS: 'ascii' }, + }); + assert.equal(child.status, 0, child.stderr); + return child.stdout; + }; + assert.equal(render('0').split('\n').length, 1); + assert.ok(render('1').split('\n').length >= 2); + assert.match(render('1'), /5h/); + assert.deepEqual(fs.readdirSync(home), []); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/tests/lib/statusline-render.test.js b/tests/lib/statusline-render.test.js new file mode 100644 index 000000000..a46fd96f4 --- /dev/null +++ b/tests/lib/statusline-render.test.js @@ -0,0 +1,288 @@ +/** + * Tests for scripts/lib/statusline-render.js + * + * Run with: node tests/lib/statusline-render.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { + C, + G, + pctColor, + buildBar, + formatCountdown, + formatMs, + computeCacheStats, + buildUsageLine, + buildSessionLine, + buildEccLine, + readInstalledPlugins, + getHooksSummary, +} = require('../../scripts/lib/statusline-render'); + +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 statusline-render.js ===\n'); + + let passed = 0; + let failed = 0; + const t = (name, fn) => (test(name, fn) ? passed++ : failed++); + + console.log('buildBar:'); + t('0% renders all empty blocks', () => { + assert.strictEqual(buildBar(0, 8), G.empty.repeat(8)); + }); + t('100% renders all full blocks', () => { + assert.strictEqual(buildBar(100, 8), G.full.repeat(8)); + }); + t('50% renders half full', () => { + assert.strictEqual(buildBar(50, 8), G.full.repeat(4) + G.empty.repeat(4)); + }); + t('clamps values beyond 100', () => { + assert.strictEqual(buildBar(250, 4), G.full.repeat(4)); + }); + t('clamps negative values', () => { + assert.strictEqual(buildBar(-10, 4), G.empty.repeat(4)); + }); + + console.log('\nglyphSet:'); + t('non-UTF-8 locale falls back to ASCII glyphs', () => { + const { glyphSet, ASCII_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'C' }), ASCII_GLYPHS); + assert.strictEqual(glyphSet({}), ASCII_GLYPHS); + }); + t('UTF-8 locale uses unicode glyphs', () => { + const { glyphSet, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8' }), UNICODE_GLYPHS); + assert.strictEqual(glyphSet({ LC_ALL: 'C.utf8' }), UNICODE_GLYPHS); + }); + t('ECC_BAR_GLYPHS forces either set', () => { + const { glyphSet, ASCII_GLYPHS, UNICODE_GLYPHS } = require('../../scripts/lib/statusline-render'); + assert.strictEqual(glyphSet({ LANG: 'en_US.UTF-8', ECC_BAR_GLYPHS: 'ascii' }), ASCII_GLYPHS); + assert.strictEqual(glyphSet({ LANG: 'C', ECC_BAR_GLYPHS: 'unicode' }), UNICODE_GLYPHS); + }); + + console.log('\npctColor:'); + t('below 60 uses brand color', () => { + assert.strictEqual(pctColor(59, C.amber), C.amber); + }); + t('60-79 is yellow', () => { + assert.strictEqual(pctColor(60), C.yellow); + }); + t('80-89 is orange', () => { + assert.strictEqual(pctColor(80), C.orange); + }); + t('90+ is red', () => { + assert.strictEqual(pctColor(95), C.red); + }); + + console.log('\nformatCountdown:'); + t('past timestamp returns empty', () => { + assert.strictEqual(formatCountdown(Date.now() / 1000 - 60), ''); + }); + t('invalid input returns empty', () => { + assert.strictEqual(formatCountdown(null), ''); + assert.strictEqual(formatCountdown('soon'), ''); + }); + t('minutes-scale countdown ends with m', () => { + const result = formatCountdown(Date.now() / 1000 + 42 * 60); + assert.ok(/^\d+m$/.test(result), `got: ${result}`); + }); + t('hours-scale countdown contains h', () => { + const result = formatCountdown(Date.now() / 1000 + 90 * 60); + assert.ok(result.includes('h'), `got: ${result}`); + }); + t('days-scale countdown ends with d', () => { + const result = formatCountdown(Date.now() / 1000 + 3 * 24 * 3600 + 60); + assert.ok(/^\d+d$/.test(result), `got: ${result}`); + }); + + console.log('\nformatMs:'); + t('zero returns empty', () => { + assert.strictEqual(formatMs(0), ''); + }); + t('seconds scale', () => { + assert.strictEqual(formatMs(30 * 1000), '30s'); + }); + t('minutes scale', () => { + assert.strictEqual(formatMs(5 * 60 * 1000), '5m'); + }); + t('hours scale with remainder', () => { + assert.strictEqual(formatMs((3 * 60 + 11) * 60 * 1000), '3h11m'); + }); + + console.log('\ncomputeCacheStats:'); + t('null usage returns null', () => { + assert.strictEqual(computeCacheStats(null), null); + }); + t('zero tokens returns null', () => { + assert.strictEqual(computeCacheStats({ input_tokens: 0 }), null); + }); + t('computes hit percentage from cache reads', () => { + const stats = computeCacheStats({ + input_tokens: 10, + cache_creation_input_tokens: 10, + cache_read_input_tokens: 80, + }); + assert.strictEqual(stats.hitPct, 80); + }); + + console.log('\nbuildUsageLine:'); + t('no rate limits returns empty string', () => { + assert.strictEqual(buildUsageLine(undefined, { hitPct: 90 }), ''); + assert.strictEqual(buildUsageLine({}, null), ''); + }); + t('renders both windows with percentages', () => { + const line = buildUsageLine({ + five_hour: { used_percentage: 71, resets_at: Date.now() / 1000 + 3600 }, + seven_day: { used_percentage: 23, resets_at: Date.now() / 1000 + 86400 * 3 }, + }, null); + assert.ok(line.includes('5h'), 'expected 5h label'); + assert.ok(line.includes('7d'), 'expected 7d label'); + assert.ok(line.includes('71%'), 'expected 5h percentage'); + assert.ok(line.includes('23%'), 'expected 7d percentage'); + assert.ok(line.includes(G.reset), 'expected reset countdown'); + }); + t('renders a single window when only one exists', () => { + const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, null); + assert.ok(line.includes('5h'), 'expected 5h label'); + assert.ok(!line.includes('7d'), 'no 7d label expected'); + }); + t('appends cache hit rate when bars exist', () => { + const line = buildUsageLine({ five_hour: { used_percentage: 5 } }, { hitPct: 98 }); + assert.ok(line.includes('cache'), 'expected cache segment'); + assert.ok(line.includes('98%'), 'expected cache percentage'); + }); + + console.log('\nbuildSessionLine:'); + t('shows model with effort badge', () => { + const line = buildSessionLine({ model: 'Opus 5', effort: 'xhigh' }); + assert.ok(line.includes('Opus 5'), 'expected model name'); + assert.ok(line.includes('xhigh'), 'expected effort badge'); + }); + t('default effort high shows no badge', () => { + const line = buildSessionLine({ model: 'Opus 5', effort: 'high' }); + assert.ok(!line.includes('high'), 'no badge expected for default effort'); + }); + t('shows cost, diff, and task', () => { + const line = buildSessionLine({ + model: 'Opus 5', + costUsd: 1.234, + linesAdded: 11, + linesRemoved: 4, + durationMs: 60 * 1000, + task: 'Fixing auth bug', + }); + assert.ok(line.includes('$1.23'), 'expected cost'); + assert.ok(line.includes('+11/-4'), 'expected diff stats'); + assert.ok(line.includes('1m'), 'expected duration'); + assert.ok(line.includes('Fixing auth bug'), 'expected task'); + }); + t('shows 1M context window size', () => { + const line = buildSessionLine({ model: 'Opus 5', ctxBar: ' bar 10%', ctxWindowSize: 1000000 }); + assert.ok(line.includes('1M'), 'expected 1M badge'); + }); + + console.log('\nbuildEccLine:'); + t('shows version, hooks profile, plugins, dirname', () => { + const line = buildEccLine({ + eccVersion: '2.2.0', + hooks: { enabled: true, profile: 'standard', disabledCount: 0 }, + plugins: [{ name: 'ecc', version: '2.2.0' }, { name: 'swift-lsp', version: '1.0.0' }], + dirname: 'myproject', + }); + assert.ok(line.includes('ECC 2.2.0'), 'expected ECC version'); + assert.ok(line.includes('hooks standard'), 'expected hooks profile'); + assert.ok(line.includes('swift-lsp'), 'expected plugin name'); + assert.ok(line.includes('1.0.0'), 'expected plugin version'); + assert.ok(line.includes('myproject'), 'expected dirname'); + }); + t('disabled hooks render hooks off', () => { + const line = buildEccLine({ hooks: { enabled: false, profile: 'standard', disabledCount: 0 } }); + assert.ok(line.includes('hooks off'), 'expected hooks off'); + }); + t('disabled hook count is shown', () => { + const line = buildEccLine({ hooks: { enabled: true, profile: 'minimal', disabledCount: 2 } }); + assert.ok(line.includes('(2 off)'), 'expected disabled count'); + }); + t('caps plugin list at 4 with overflow count', () => { + const plugins = ['a', 'b', 'c', 'd', 'e', 'f'].map(name => ({ name, version: '1.0.0' })); + const line = buildEccLine({ plugins }); + assert.ok(line.includes('+2'), 'expected +2 overflow'); + }); + + console.log('\nreadInstalledPlugins:'); + t('reads v2 plugin file and filters disabled plugins, ecc first', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); + try { + fs.mkdirSync(path.join(tmpDir, 'plugins'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, 'plugins', 'installed_plugins.json'), JSON.stringify({ + version: 2, + plugins: { + 'zeta@market': [{ version: '0.5.0', scope: 'user' }], + 'ecc@ecc': [{ version: '2.2.0', scope: 'user' }], + 'off-plugin@market': [{ version: '9.9.9', scope: 'user' }], + }, + })); + fs.writeFileSync(path.join(tmpDir, 'settings.json'), JSON.stringify({ + enabledPlugins: { 'zeta@market': true, 'ecc@ecc': true, 'off-plugin@market': false }, + })); + + const plugins = readInstalledPlugins(tmpDir); + assert.strictEqual(plugins.length, 2); + assert.strictEqual(plugins[0].name, 'ecc'); + assert.strictEqual(plugins[0].version, '2.2.0'); + assert.strictEqual(plugins[1].name, 'zeta'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + t('missing plugin file returns empty array', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-render-test-')); + try { + assert.deepStrictEqual(readInstalledPlugins(tmpDir), []); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + console.log('\ngetHooksSummary:'); + t('defaults to enabled standard profile', () => { + const summary = getHooksSummary({}); + assert.strictEqual(summary.enabled, true); + assert.strictEqual(summary.profile, 'standard'); + assert.strictEqual(summary.disabledCount, 0); + }); + t('respects ECC_HOOKS_ENABLED=false', () => { + assert.strictEqual(getHooksSummary({ ECC_HOOKS_ENABLED: 'false' }).enabled, false); + }); + t('reports profile and disabled hook count', () => { + const summary = getHooksSummary({ + ECC_HOOK_PROFILE: 'minimal', + ECC_DISABLED_HOOKS: 'a,b', + }); + assert.strictEqual(summary.profile, 'minimal'); + assert.strictEqual(summary.disabledCount, 2); + }); + + console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + return { passed, failed }; +} + +const { failed } = runTests(); +process.exit(failed > 0 ? 1 : 0);