mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
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.
This commit is contained in:
5 files changed
+697
-1
No files matched your search
@@ -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.
|
||||||
@@ -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() {
|
function runStatusline() {
|
||||||
let input = '';
|
let input = '';
|
||||||
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
|
||||||
@@ -119,6 +181,11 @@ function runStatusline() {
|
|||||||
// Current task
|
// Current task
|
||||||
const task = sessionId ? readCurrentTask(sessionId) : '';
|
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
|
// Metrics from bridge
|
||||||
let metricsStr = '';
|
let metricsStr = '';
|
||||||
if (bridge) {
|
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();
|
if (require.main === module) runStatusline();
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
Reference in new issue
Block a user