claude-doom-statusbar 0.7.1 → 0.8.0

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,7 @@ $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
75
 
76
76
  ### Responsive width
77
77
 
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.0",
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
 
@@ -147,6 +149,12 @@ const SPARK_BRAILLE = [
147
149
  "⠀⢀⢠⢰⢸", "⡀⣀⣠⣰⣸", "⡄⣄⣤⣴⣼", "⡆⣆⣦⣶⣾", "⡇⣇⣧⣷⣿",
148
150
  ].map((r) => [...r]);
149
151
  const BLOCK_RAMP = [..."▁▂▃▄▅▆▇"];
152
+ // The equalizer's OWN 9-level height ramp (empty 0/8 .. full 8/8), so an idle
153
+ // channel reads as empty and a maxed one as a full block -- deliberately wider at
154
+ // both ends than spark's BLOCK_RAMP, which never shows empty or full. Index via
155
+ // pyround(clamp(v,0,1) * 8).
156
+ const EQ_RAMP = [..." ▁▂▃▄▅▆▇█"];
157
+ const EQ_MAX = 16; // column cap: more channels than this densify by averaging
150
158
 
151
159
  function rSpark(values, style = "block", boxRgb = TERM_RGB, vmax = null) {
152
160
  const empty = [0, 1, 2].map((i) => Math.floor((boxRgb[i] + TERM_RGB[i]) / 2));
@@ -178,6 +186,36 @@ function rSpark(values, style = "block", boxRgb = TERM_RGB, vmax = null) {
178
186
  return bg + f(SPARK) + body + bgsgrBox(boxRgb);
179
187
  }
180
188
 
189
+ // Densify N channel values into exactly K = min(N, EQ_MAX) columns by averaging
190
+ // each column's contiguous slice. K columns by construction, so the rendered width
191
+ // always equals metricFixedWidth's min(N, EQ_MAX) -- the box-layout invariant.
192
+ function eqColumns(values) {
193
+ const n = values.length;
194
+ const k = Math.min(n, EQ_MAX);
195
+ const cols = [];
196
+ for (let i = 0; i < k; i++) {
197
+ const lo = Math.floor((i * n) / k), hi = Math.floor(((i + 1) * n) / k);
198
+ let sum = 0;
199
+ for (let j = lo; j < hi; j++) sum += values[j];
200
+ cols.push(sum / (hi - lo));
201
+ }
202
+ return cols;
203
+ }
204
+
205
+ // One-row VU-meter: one block column per channel (densified past EQ_MAX), each
206
+ // column coloured by its OWN value via threshold(). Absolute 0..1 scale (no
207
+ // span-normalize), 9-level EQ_RAMP. Fixed-width: takes no `cells` budget.
208
+ function rEqualizer(values, boxRgb) {
209
+ if (!values || values.length === 0) return f(SPARK);
210
+ const empty = [0, 1, 2].map((i) => Math.floor((boxRgb[i] + TERM_RGB[i]) / 2));
211
+ let body = sgrBg(empty);
212
+ for (const raw of eqColumns(values)) {
213
+ const v = Math.max(0, Math.min(1, raw));
214
+ body += f(threshold(v * 100)) + EQ_RAMP[pyround(v * 8)];
215
+ }
216
+ return body + bgsgrBox(boxRgb);
217
+ }
218
+
181
219
  export function renderValue(entry, cells, boxRgb) {
182
220
  const icon = entry.icon || "";
183
221
  const label = icon ? icon + " " : "";
@@ -198,6 +236,7 @@ export function renderValue(entry, cells, boxRgb) {
198
236
  }
199
237
  if (render === "ammo") return label + rAmmo(val, color || "threshold");
200
238
  if (render === "spark") return label + rSpark(val, entry.spark_style || "block", boxRgb, entry.spark_max);
239
+ if (render === "equalizer") return label + rEqualizer(val, boxRgb);
201
240
  if (render === "list") {
202
241
  const items = VALUES[entry.id] || [];
203
242
  return label + f(TEXT) + items.map((x) => (Array.isArray(x) ? `${x[0]} ${x[1]}` : String(x))).join(" ");
@@ -253,6 +292,7 @@ export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
253
292
  return lw + capLen(entry.group.filter((i) => i in VALUES).map((i) => String(VALUES[i])).join(sep), textCap) + rextra;
254
293
  }
255
294
  if (r === "spark") return lw + Math.floor(((VALUES[entry.id] || []).length + 1) / 2);
295
+ if (r === "equalizer") return lw + Math.min((VALUES[entry.id] || []).length, EQ_MAX);
256
296
  if (r === "ammo") return lw + 5 + vlen(" " + (entry.id in VALUES ? VALUES[entry.id] : 0) + "%");
257
297
  if (r === "list") {
258
298
  const items = VALUES[entry.id] || [];
package/src/statusline.js CHANGED
@@ -239,26 +239,50 @@ 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
+ // Per-core idle fraction over a cumulative-time delta; null when no time elapsed.
243
+ const cpuUtil = (dt, di) => (dt > 0 ? Math.max(0, Math.min(1, 1 - di / dt)) : null);
244
+
245
+ // Pure: turn two cumulative CPU snapshots into the aggregate percent (sys.cpu, 0..100)
246
+ // and the per-core utilisation array (sys.cores, 0..1 each). Cold start (no prev) or a
247
+ // core-count mismatch (e.g. an old cache without `cores`) yields null for that field, so
248
+ // the metric simply doesn't render that refresh. A core with no elapsed time reads 0.
249
+ export function cpuDeltas(prev, cur) {
250
+ if (!prev) return { cpu: null, cores: null };
251
+ const cpu = cpuUtil(cur.total - prev.total, cur.idle - prev.idle);
252
+ let cores = null;
253
+ if (Array.isArray(prev.cores) && Array.isArray(cur.cores) && prev.cores.length === cur.cores.length) {
254
+ cores = cur.cores.map((c, i) => {
255
+ const u = cpuUtil(c.total - prev.cores[i].total, c.idle - prev.cores[i].idle);
256
+ return u === null ? 0 : u;
257
+ });
258
+ }
259
+ return { cpu: cpu === null ? null : pyround(cpu * 100), cores };
260
+ }
261
+
262
+ function cpuMetrics() {
263
+ let cores;
244
264
  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; }
265
+ cores = os.cpus().map((c) => {
266
+ let total = 0; for (const k in c.times) total += c.times[k];
267
+ return { total, idle: c.times.idle };
268
+ });
269
+ } catch { return { cpu: null, cores: null }; }
270
+ const agg = cores.reduce((a, c) => ({ total: a.total + c.total, idle: a.idle + c.idle }), { total: 0, idle: 0 });
271
+ const cur = { total: agg.total, idle: agg.idle, cores };
247
272
  const cache = path.join(TMP, "mugshot_cpu.json");
248
273
  let prev = null;
249
274
  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;
275
+ try { writeFileSync(cache, JSON.stringify(cur)); } catch { /* ignore */ }
276
+ return cpuDeltas(prev, cur);
254
277
  }
255
278
 
256
279
  function sysValues(cwd) {
257
280
  const v = {};
258
281
  const ram = ramPercent();
259
282
  if (ram !== null) v["sys.ram"] = ram;
260
- const cpu = cpuPercent();
283
+ const { cpu, cores } = cpuMetrics();
261
284
  if (cpu !== null) v["sys.cpu"] = `${cpu}%`;
285
+ if (cores) v["sys.cores"] = cores;
262
286
  try {
263
287
  const stat = statfsSync(cwd || process.cwd());
264
288
  v["sys.disk"] = pyround(((stat.blocks - stat.bfree) / stat.blocks) * 100);