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 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.7.1",
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", icon = "🔥" },
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
- function threshold(pct) {
92
- return pct < 60 ? OK : pct < 85 ? WARN : CRIT;
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 === "threshold" ? threshold(pct) : colorSpec ? rgbOf(colorSpec) : TEXT;
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 === "threshold" ? threshold(pct) : WARN;
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
- let col;
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
- let col;
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
- function cpuPercent() {
243
- let total = 0, idle = 0;
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
- for (const c of os.cpus()) { for (const k in c.times) total += c.times[k]; idle += c.times.idle; }
246
- } catch { return null; }
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
- try { writeFileSync(cache, JSON.stringify({ total, idle })); } catch { /* ignore */ }
251
- if (!prev) return null;
252
- const dt = total - prev.total, di = idle - prev.idle;
253
- return dt > 0 ? pyround(Math.max(0, Math.min(100, 100 * (1 - di / dt)))) : null;
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 = cpuPercent();
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);