claude-doom-statusbar 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -2
- package/package.json +2 -2
- package/presets/full.toml +2 -1
- package/src/render.js +80 -13
- package/src/statusline.js +52 -9
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ The HUD is a row of boxes centred on the mugshot. Each box is configurable; the
|
|
|
19
19
|
- **ACTIVITY** — a tool-activity "geiger" sparkline (duty-cycle over the last 30 s), running-agent count, task progress, error count.
|
|
20
20
|
- **AGENTS** — a live list of running subagents (type/description + ticking runtime), always visible. Long lists scroll within the box height, with ↑/↓ markers counting the rows hidden off-screen.
|
|
21
21
|
- **TASKS** — the session's todo list: settled items (✅ done, ❌ removed) on top, open items (⏩ in-progress, 🎯 pending) below. Scrolls like AGENTS, anchored on the open/settled boundary.
|
|
22
|
-
- **SYS** — CPU, disk, session length, wall clock.
|
|
22
|
+
- **SYS** — CPU, a per-core CPU equalizer (one threshold-coloured column per core), disk, session length, wall clock.
|
|
23
23
|
|
|
24
24
|
Anything the session can't supply is hidden automatically, so the same config degrades cleanly.
|
|
25
25
|
|
|
@@ -71,7 +71,13 @@ $env:FORCE_HYPERLINK = "1"; claude
|
|
|
71
71
|
- **`standard`** — balanced HUD.
|
|
72
72
|
- **`full`** — every box, the look in the screenshot above.
|
|
73
73
|
|
|
74
|
-
A preset is TOML: a `[bar]` style block, a `[mugshot]` block, and a list of `[[segment]]` boxes. Each box lists metrics with a render type — `bar`, `number`, `text`, `spark`, `ammo`, `list`, `scroll`, or a `group`. Copy one and rearrange the boxes, swap icons, or change which metrics show.
|
|
74
|
+
A preset is TOML: a `[bar]` style block, a `[mugshot]` block, and a list of `[[segment]]` boxes. Each box lists metrics with a render type — `bar`, `number`, `text`, `spark`, `equalizer`, `ammo`, `list`, `scroll`, or a `group`. (`equalizer` draws an array of `0..1` values as a one-row VU-meter: one block column per channel, each coloured by its own value — e.g. `sys.cores` for per-core CPU.) Copy one and rearrange the boxes, swap icons, or change which metrics show.
|
|
75
|
+
|
|
76
|
+
A value-carrying metric (`bar`, `ammo`, `equalizer`, `number`, `text`) can set `color`:
|
|
77
|
+
|
|
78
|
+
- `color = "threshold"` — the default heat gradient: green at 0, yellow at 50, red at 100, smoothly interpolated.
|
|
79
|
+
- `color = "#rrggbb"` — a solid colour.
|
|
80
|
+
- `color = [[0, "#60c868"], [50, "#e0b840"], [100, "#e05440"]]` — custom gradient stops as `[value, "#hex"]` pairs, interpolated between stops. A single pair is a solid colour; adjacent stops (e.g. `[50, "#..."], [51, "#..."]`) make a hard step instead of a smooth blend.
|
|
75
81
|
|
|
76
82
|
### Responsive width
|
|
77
83
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-doom-statusbar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "DOOM-inspired status bar for the Claude Code CLI — a mugshot that tracks session health, plus usage, model, project, system, and a live subagent list.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"statusline": "node src/statusline.js",
|
|
17
17
|
"hook": "node src/hook.js",
|
|
18
|
-
"test": "node test/installer.test.mjs && node test/smoke.test.mjs && node test/hook-tasks.test.mjs && node test/statusline-tasks.test.mjs && node test/render-scroll.test.mjs && node test/statusline-savings.test.mjs && node test/e2e-tasks.test.mjs && node test/marquee.test.mjs && node test/layout.test.mjs && node test/resolve-preset.test.mjs && node test/preset-bands.test.mjs && node test/journal.test.mjs && node test/git-event.test.mjs",
|
|
18
|
+
"test": "node test/installer.test.mjs && node test/smoke.test.mjs && node test/hook-tasks.test.mjs && node test/statusline-tasks.test.mjs && node test/render-scroll.test.mjs && node test/render-equalizer.test.mjs && node test/statusline-savings.test.mjs && node test/statusline-cores.test.mjs && node test/e2e-tasks.test.mjs && node test/marquee.test.mjs && node test/layout.test.mjs && node test/resolve-preset.test.mjs && node test/preset-bands.test.mjs && node test/journal.test.mjs && node test/git-event.test.mjs",
|
|
19
19
|
"preversion": "npm test",
|
|
20
20
|
"postversion": "git push --follow-tags",
|
|
21
21
|
"prepublishOnly": "npm test"
|
package/presets/full.toml
CHANGED
|
@@ -87,7 +87,8 @@ metric = [
|
|
|
87
87
|
type = "box"
|
|
88
88
|
title = "SYS"
|
|
89
89
|
metric = [
|
|
90
|
-
{ id = "sys.cpu", render = "number",
|
|
90
|
+
{ id = "sys.cpu", render = "number", icon = "🔥" },
|
|
91
|
+
{ id = "sys.cores", render = "equalizer", icon = "📊", color = "threshold" },
|
|
91
92
|
{ id = "sys.disk", render = "bar", icon = "💿", color = "threshold" },
|
|
92
93
|
{ id = "sys.session", render = "text", icon = "🕙" },
|
|
93
94
|
{ id = "sys.clock", render = "text", icon = "🕓" },
|
package/src/render.js
CHANGED
|
@@ -47,6 +47,8 @@ export const SAMPLE = {
|
|
|
47
47
|
"act.geiger": [0, .25, .5, 1, .75, 1, .5, .6, .3, .1, .4, 1, .8, .4],
|
|
48
48
|
"act.tasks": "2/5", "act.errors": "0", "sys.ram": 47, "sys.cpu": "12%",
|
|
49
49
|
"sys.disk": 63, "sys.clock": "14:23",
|
|
50
|
+
"sys.cores": [0.12, 0.30, 0.08, 0.95, 0.45, 0.18, 0.62, 0.22],
|
|
51
|
+
|
|
50
52
|
"save.leanctx": "8.3k 63%", "save.lingua": "1.2k 1.3x",
|
|
51
53
|
};
|
|
52
54
|
|
|
@@ -88,8 +90,42 @@ function headCols(text, width) {
|
|
|
88
90
|
return out;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
// Default heat gradient: green (0) -> yellow (50) -> red (100). Higher = hotter,
|
|
94
|
+
// the orientation every caller wants (context usage, rate limits, CPU, disk, RAM).
|
|
95
|
+
const DEFAULT_STOPS = [[0, OK], [50, WARN], [100, CRIT]];
|
|
96
|
+
|
|
97
|
+
const lerp1 = (a, b, t) => Math.round(a + (b - a) * t);
|
|
98
|
+
|
|
99
|
+
// Interpolate an RGB colour at `pct` (0..100) across sorted [value, rgb] stops.
|
|
100
|
+
// Below the first stop / above the last it clamps to that stop. A single stop is a
|
|
101
|
+
// solid colour; adjacent stops (e.g. 50 then 51) make a near-hard transition.
|
|
102
|
+
function interpStops(stops, pct) {
|
|
103
|
+
if (pct <= stops[0][0]) return stops[0][1];
|
|
104
|
+
const last = stops[stops.length - 1];
|
|
105
|
+
if (pct >= last[0]) return last[1];
|
|
106
|
+
for (let i = 1; i < stops.length; i++) {
|
|
107
|
+
const [v0, c0] = stops[i - 1], [v1, c1] = stops[i];
|
|
108
|
+
if (pct <= v1) {
|
|
109
|
+
const t = v1 === v0 ? 0 : (pct - v0) / (v1 - v0);
|
|
110
|
+
return [0, 1, 2].map((j) => lerp1(c0[j], c1[j], t));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return last[1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Resolve a metric's `color` spec to an RGB at `pct` (0..100):
|
|
117
|
+
// "threshold" -> the default green->yellow->red gradient
|
|
118
|
+
// [[v, "#hex"], ...] -> a custom gradient (a single pair = a solid colour)
|
|
119
|
+
// "#rrggbb" -> a solid colour
|
|
120
|
+
// unset / unknown -> null, so the caller can pick its own default
|
|
121
|
+
function colorFor(spec, pct) {
|
|
122
|
+
if (spec === "threshold") return interpStops(DEFAULT_STOPS, pct);
|
|
123
|
+
if (Array.isArray(spec) && spec.length) {
|
|
124
|
+
const stops = spec.map(([v, c]) => [v, rgbOf(c)]).sort((a, b) => a[0] - b[0]);
|
|
125
|
+
return interpStops(stops, pct);
|
|
126
|
+
}
|
|
127
|
+
if (typeof spec === "string" && spec.startsWith("#")) return rgbOf(spec);
|
|
128
|
+
return null;
|
|
93
129
|
}
|
|
94
130
|
|
|
95
131
|
function rgbOf(spec) {
|
|
@@ -118,7 +154,7 @@ function rBar(pct, cells, boxRgb, colorSpec, showPct = true) {
|
|
|
118
154
|
const eighths = pyround((pct / 100) * cells * 8);
|
|
119
155
|
const full = Math.min(cells, Math.floor(eighths / 8));
|
|
120
156
|
const rem = full < cells ? eighths % 8 : 0;
|
|
121
|
-
const c = colorSpec
|
|
157
|
+
const c = colorFor(colorSpec, pct) ?? TEXT;
|
|
122
158
|
let s = sgrBg(empty) + f(c) + "█".repeat(full);
|
|
123
159
|
if (rem) s += EIGHTHS[rem];
|
|
124
160
|
s += " ".repeat(Math.max(0, cells - full - (rem ? 1 : 0)));
|
|
@@ -128,7 +164,7 @@ function rBar(pct, cells, boxRgb, colorSpec, showPct = true) {
|
|
|
128
164
|
}
|
|
129
165
|
|
|
130
166
|
function rAmmo(pct, colorSpec, segs = 5) {
|
|
131
|
-
const c = colorSpec
|
|
167
|
+
const c = colorFor(colorSpec, pct) ?? WARN;
|
|
132
168
|
const filled = pyround((pct / 100) * segs);
|
|
133
169
|
return f(c) + "▮".repeat(filled) + f([90, 95, 120]) + "▯".repeat(segs - filled) +
|
|
134
170
|
f(c) + " " + String(pct).padStart(3) + "%";
|
|
@@ -147,6 +183,12 @@ const SPARK_BRAILLE = [
|
|
|
147
183
|
"⠀⢀⢠⢰⢸", "⡀⣀⣠⣰⣸", "⡄⣄⣤⣴⣼", "⡆⣆⣦⣶⣾", "⡇⣇⣧⣷⣿",
|
|
148
184
|
].map((r) => [...r]);
|
|
149
185
|
const BLOCK_RAMP = [..."▁▂▃▄▅▆▇"];
|
|
186
|
+
// The equalizer's OWN 9-level height ramp (empty 0/8 .. full 8/8), so an idle
|
|
187
|
+
// channel reads as empty and a maxed one as a full block -- deliberately wider at
|
|
188
|
+
// both ends than spark's BLOCK_RAMP, which never shows empty or full. Index via
|
|
189
|
+
// pyround(clamp(v,0,1) * 8).
|
|
190
|
+
const EQ_RAMP = [..." ▁▂▃▄▅▆▇█"];
|
|
191
|
+
const EQ_MAX = 16; // column cap: more channels than this densify by averaging
|
|
150
192
|
|
|
151
193
|
function rSpark(values, style = "block", boxRgb = TERM_RGB, vmax = null) {
|
|
152
194
|
const empty = [0, 1, 2].map((i) => Math.floor((boxRgb[i] + TERM_RGB[i]) / 2));
|
|
@@ -178,6 +220,36 @@ function rSpark(values, style = "block", boxRgb = TERM_RGB, vmax = null) {
|
|
|
178
220
|
return bg + f(SPARK) + body + bgsgrBox(boxRgb);
|
|
179
221
|
}
|
|
180
222
|
|
|
223
|
+
// Densify N channel values into exactly K = min(N, EQ_MAX) columns by averaging
|
|
224
|
+
// each column's contiguous slice. K columns by construction, so the rendered width
|
|
225
|
+
// always equals metricFixedWidth's min(N, EQ_MAX) -- the box-layout invariant.
|
|
226
|
+
function eqColumns(values) {
|
|
227
|
+
const n = values.length;
|
|
228
|
+
const k = Math.min(n, EQ_MAX);
|
|
229
|
+
const cols = [];
|
|
230
|
+
for (let i = 0; i < k; i++) {
|
|
231
|
+
const lo = Math.floor((i * n) / k), hi = Math.floor(((i + 1) * n) / k);
|
|
232
|
+
let sum = 0;
|
|
233
|
+
for (let j = lo; j < hi; j++) sum += values[j];
|
|
234
|
+
cols.push(sum / (hi - lo));
|
|
235
|
+
}
|
|
236
|
+
return cols;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// One-row VU-meter: one block column per channel (densified past EQ_MAX), each
|
|
240
|
+
// column coloured by its OWN value via the metric's colour spec (default gradient).
|
|
241
|
+
// Absolute 0..1 scale (no span-normalize), 9-level EQ_RAMP. Fixed-width: no `cells`.
|
|
242
|
+
function rEqualizer(values, boxRgb, colorSpec) {
|
|
243
|
+
if (!values || values.length === 0) return f(SPARK);
|
|
244
|
+
const empty = [0, 1, 2].map((i) => Math.floor((boxRgb[i] + TERM_RGB[i]) / 2));
|
|
245
|
+
let body = sgrBg(empty);
|
|
246
|
+
for (const raw of eqColumns(values)) {
|
|
247
|
+
const v = Math.max(0, Math.min(1, raw));
|
|
248
|
+
body += f(colorFor(colorSpec, v * 100) ?? OK) + EQ_RAMP[pyround(v * 8)];
|
|
249
|
+
}
|
|
250
|
+
return body + bgsgrBox(boxRgb);
|
|
251
|
+
}
|
|
252
|
+
|
|
181
253
|
export function renderValue(entry, cells, boxRgb) {
|
|
182
254
|
const icon = entry.icon || "";
|
|
183
255
|
const label = icon ? icon + " " : "";
|
|
@@ -198,17 +270,13 @@ export function renderValue(entry, cells, boxRgb) {
|
|
|
198
270
|
}
|
|
199
271
|
if (render === "ammo") return label + rAmmo(val, color || "threshold");
|
|
200
272
|
if (render === "spark") return label + rSpark(val, entry.spark_style || "block", boxRgb, entry.spark_max);
|
|
273
|
+
if (render === "equalizer") return label + rEqualizer(val, boxRgb, color || "threshold");
|
|
201
274
|
if (render === "list") {
|
|
202
275
|
const items = VALUES[entry.id] || [];
|
|
203
276
|
return label + f(TEXT) + items.map((x) => (Array.isArray(x) ? `${x[0]} ${x[1]}` : String(x))).join(" ");
|
|
204
277
|
}
|
|
205
278
|
// number / text
|
|
206
|
-
|
|
207
|
-
if (color === "threshold") {
|
|
208
|
-
col = threshold(parseInt(String(val).replace(/\D/g, "") || "0", 10));
|
|
209
|
-
} else {
|
|
210
|
-
col = color ? rgbOf(color) : TEXT;
|
|
211
|
-
}
|
|
279
|
+
const col = colorFor(color, parseInt(String(val).replace(/\D/g, "") || "0", 10)) ?? TEXT;
|
|
212
280
|
return label + f(col) + String(val);
|
|
213
281
|
}
|
|
214
282
|
|
|
@@ -253,6 +321,7 @@ export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
|
|
|
253
321
|
return lw + capLen(entry.group.filter((i) => i in VALUES).map((i) => String(VALUES[i])).join(sep), textCap) + rextra;
|
|
254
322
|
}
|
|
255
323
|
if (r === "spark") return lw + Math.floor(((VALUES[entry.id] || []).length + 1) / 2);
|
|
324
|
+
if (r === "equalizer") return lw + Math.min((VALUES[entry.id] || []).length, EQ_MAX);
|
|
256
325
|
if (r === "ammo") return lw + 5 + vlen(" " + (entry.id in VALUES ? VALUES[entry.id] : 0) + "%");
|
|
257
326
|
if (r === "list") {
|
|
258
327
|
const items = VALUES[entry.id] || [];
|
|
@@ -557,9 +626,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0, overflow) {
|
|
|
557
626
|
const budget = w - vlen(lbl) - vlen(rhs);
|
|
558
627
|
const sliceable = !ESC_RE.test(raw) || OSC8_RE.test(raw); // plain or hyperlink
|
|
559
628
|
if (sliceable && budget > 0 && vlen(raw) > budget) {
|
|
560
|
-
|
|
561
|
-
if (m.color === "threshold") col = threshold(parseInt(raw.replace(/\D/g, "") || "0", 10));
|
|
562
|
-
else col = m.color ? rgbOf(m.color) : TEXT;
|
|
629
|
+
const col = colorFor(m.color, parseInt(raw.replace(/\D/g, "") || "0", 10)) ?? TEXT;
|
|
563
630
|
body = lbl + f(col) + marquee(raw, budget, tick, ovf);
|
|
564
631
|
}
|
|
565
632
|
}
|
package/src/statusline.js
CHANGED
|
@@ -239,26 +239,69 @@ function ramPercent() {
|
|
|
239
239
|
try { return pyround((1 - os.freemem() / os.totalmem()) * 100); } catch { return null; }
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
// Minimum interval between CPU snapshots; below this, per-core deltas are tick-quantised noise.
|
|
243
|
+
const CPU_MIN_MS = 1000;
|
|
244
|
+
|
|
245
|
+
// Pure: should cpuMetrics recompute, or hold the cached result? Recompute on cold start,
|
|
246
|
+
// a legacy snapshot without `ts`, a missing cached result, or once CPU_MIN_MS has elapsed.
|
|
247
|
+
// Holding in between keeps a burst of fast refreshes from sampling a sub-tick interval.
|
|
248
|
+
export function shouldSampleCpu(prev, now) {
|
|
249
|
+
return !(prev && typeof prev.ts === "number" && now - prev.ts < CPU_MIN_MS && prev.result);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Per-core idle fraction over a cumulative-time delta; null when no time elapsed.
|
|
253
|
+
const cpuUtil = (dt, di) => (dt > 0 ? Math.max(0, Math.min(1, 1 - di / dt)) : null);
|
|
254
|
+
|
|
255
|
+
// Pure: turn two cumulative CPU snapshots into the aggregate percent (sys.cpu, 0..100)
|
|
256
|
+
// and the per-core utilisation array (sys.cores, 0..1 each). Cold start (no prev) or a
|
|
257
|
+
// core-count mismatch (e.g. an old cache without `cores`) yields null for that field, so
|
|
258
|
+
// the metric simply doesn't render that refresh. A core with no elapsed time reads 0.
|
|
259
|
+
export function cpuDeltas(prev, cur) {
|
|
260
|
+
if (!prev) return { cpu: null, cores: null };
|
|
261
|
+
const cpu = cpuUtil(cur.total - prev.total, cur.idle - prev.idle);
|
|
262
|
+
let cores = null;
|
|
263
|
+
if (Array.isArray(prev.cores) && Array.isArray(cur.cores) && prev.cores.length === cur.cores.length) {
|
|
264
|
+
cores = cur.cores.map((c, i) => {
|
|
265
|
+
const u = cpuUtil(c.total - prev.cores[i].total, c.idle - prev.cores[i].idle);
|
|
266
|
+
return u === null ? 0 : u;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return { cpu: cpu === null ? null : pyround(cpu * 100), cores };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function cpuMetrics() {
|
|
273
|
+
let cores;
|
|
244
274
|
try {
|
|
245
|
-
|
|
246
|
-
|
|
275
|
+
cores = os.cpus().map((c) => {
|
|
276
|
+
let total = 0; for (const k in c.times) total += c.times[k];
|
|
277
|
+
return { total, idle: c.times.idle };
|
|
278
|
+
});
|
|
279
|
+
} catch { return { cpu: null, cores: null }; }
|
|
280
|
+
const agg = cores.reduce((a, c) => ({ total: a.total + c.total, idle: a.idle + c.idle }), { total: 0, idle: 0 });
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
const cur = { ts: now, total: agg.total, idle: agg.idle, cores };
|
|
247
283
|
const cache = path.join(TMP, "mugshot_cpu.json");
|
|
248
284
|
let prev = null;
|
|
249
285
|
try { prev = JSON.parse(readFileSync(cache, "utf8")); } catch { /* none */ }
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
286
|
+
// Windows updates os.cpus() times only on the ~15.6ms scheduler tick, so a per-core
|
|
287
|
+
// delta over a sub-second interval is dominated by tick quantisation and explodes
|
|
288
|
+
// into 0/100 noise. The status bar refreshes on every action, often milliseconds
|
|
289
|
+
// apart -- so only recompute when >= CPU_MIN_MS has elapsed, holding the last result
|
|
290
|
+
// (and the old snapshot) in between. The interval keeps growing until it's wide
|
|
291
|
+
// enough to be stable, even under a burst of fast refreshes.
|
|
292
|
+
if (!shouldSampleCpu(prev, now)) return prev.result;
|
|
293
|
+
const result = cpuDeltas(prev, cur);
|
|
294
|
+
try { writeFileSync(cache, JSON.stringify({ ...cur, result })); } catch { /* ignore */ }
|
|
295
|
+
return result;
|
|
254
296
|
}
|
|
255
297
|
|
|
256
298
|
function sysValues(cwd) {
|
|
257
299
|
const v = {};
|
|
258
300
|
const ram = ramPercent();
|
|
259
301
|
if (ram !== null) v["sys.ram"] = ram;
|
|
260
|
-
const cpu =
|
|
302
|
+
const { cpu, cores } = cpuMetrics();
|
|
261
303
|
if (cpu !== null) v["sys.cpu"] = `${cpu}%`;
|
|
304
|
+
if (cores) v["sys.cores"] = cores;
|
|
262
305
|
try {
|
|
263
306
|
const stat = statfsSync(cwd || process.cwd());
|
|
264
307
|
v["sys.disk"] = pyround(((stat.blocks - stat.bfree) / stat.blocks) * 100);
|