claude-doom-statusbar 0.8.0 → 0.8.2

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
@@ -3,7 +3,7 @@
3
3
  A DOOM-inspired status bar for the [Claude Code](https://docs.claude.com/en/docs/claude-code) CLI. Your session, read off the Doomguy HUD: a mugshot whose face tracks your health, boxes for usage, model, project and system, and live lists of running agents and tasks.
4
4
 
5
5
  <p align="center">
6
- <img src="assets/images/hud.png" alt="claude-doom-statusbar HUD: MODEL, USAGE, PROJECT, the DOOM mugshot, ACTIVITY, AGENTS, TASKS and SYS boxes">
6
+ <img src="assets/images/hud.png" alt="claude-doom-statusbar HUD: MODEL, USAGE, PROJECT, the DOOM mugshot, ACTIVITY, AGENTS, TASKS and SYSTEM boxes">
7
7
  </p>
8
8
 
9
9
  The mugshot is the real DOOM (1993) status-face sprite, rasterised into the terminal at runtime — not ASCII art of it.
@@ -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, a per-core CPU equalizer (one threshold-coloured column per core), disk, session length, wall clock.
22
+ - **SYSTEM** — a per-core CPU equalizer (one threshold-coloured column per core) with the aggregate CPU % right-aligned beside it, 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
 
@@ -73,6 +73,12 @@ $env:FORCE_HYPERLINK = "1"; claude
73
73
 
74
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
+ 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.
81
+
76
82
  ### Responsive width
77
83
 
78
84
  As the terminal narrows, the HUD shrinks: bars contract and text columns shrink together, and whatever overflows scrolls (the marquee). When even the smallest layout no longer fits, the preset falls back to a smaller one via its `[bar].fallback` key — `full → standard → minimal`. Your chosen preset is the ceiling; widening the terminal recovers it. It's stateless — each refresh re-reads the terminal width (`COLUMNS`), so it follows live resizes.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-doom-statusbar",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
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": {
package/presets/full.toml CHANGED
@@ -85,10 +85,9 @@ metric = [
85
85
 
86
86
  [[segment]]
87
87
  type = "box"
88
- title = "SYS"
88
+ title = "SYSTEM"
89
89
  metric = [
90
- { id = "sys.cpu", render = "number", icon = "🔥" },
91
- { id = "sys.cores", render = "equalizer", icon = "🎚", color = "threshold" },
90
+ { id = "sys.cores", render = "equalizer", icon = "🔥", color = "threshold", right = "sys.cpu" },
92
91
  { id = "sys.disk", render = "bar", icon = "💿", color = "threshold" },
93
92
  { id = "sys.session", render = "text", icon = "🕙" },
94
93
  { id = "sys.clock", render = "text", icon = "🕓" },
@@ -1,4 +1,4 @@
1
- # standard — full minus SAVE and SYS
1
+ # standard — full minus SAVE and SYSTEM
2
2
  [bar]
3
3
  border_style = "vertical"
4
4
  border_color = "term-bg" # seamless cuts through the panel
package/src/render.js CHANGED
@@ -90,8 +90,42 @@ function headCols(text, width) {
90
90
  return out;
91
91
  }
92
92
 
93
- function threshold(pct) {
94
- 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;
95
129
  }
96
130
 
97
131
  function rgbOf(spec) {
@@ -120,7 +154,7 @@ function rBar(pct, cells, boxRgb, colorSpec, showPct = true) {
120
154
  const eighths = pyround((pct / 100) * cells * 8);
121
155
  const full = Math.min(cells, Math.floor(eighths / 8));
122
156
  const rem = full < cells ? eighths % 8 : 0;
123
- const c = colorSpec === "threshold" ? threshold(pct) : colorSpec ? rgbOf(colorSpec) : TEXT;
157
+ const c = colorFor(colorSpec, pct) ?? TEXT;
124
158
  let s = sgrBg(empty) + f(c) + "█".repeat(full);
125
159
  if (rem) s += EIGHTHS[rem];
126
160
  s += " ".repeat(Math.max(0, cells - full - (rem ? 1 : 0)));
@@ -130,7 +164,7 @@ function rBar(pct, cells, boxRgb, colorSpec, showPct = true) {
130
164
  }
131
165
 
132
166
  function rAmmo(pct, colorSpec, segs = 5) {
133
- const c = colorSpec === "threshold" ? threshold(pct) : WARN;
167
+ const c = colorFor(colorSpec, pct) ?? WARN;
134
168
  const filled = pyround((pct / 100) * segs);
135
169
  return f(c) + "▮".repeat(filled) + f([90, 95, 120]) + "▯".repeat(segs - filled) +
136
170
  f(c) + " " + String(pct).padStart(3) + "%";
@@ -203,15 +237,15 @@ function eqColumns(values) {
203
237
  }
204
238
 
205
239
  // 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) {
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) {
209
243
  if (!values || values.length === 0) return f(SPARK);
210
244
  const empty = [0, 1, 2].map((i) => Math.floor((boxRgb[i] + TERM_RGB[i]) / 2));
211
245
  let body = sgrBg(empty);
212
246
  for (const raw of eqColumns(values)) {
213
247
  const v = Math.max(0, Math.min(1, raw));
214
- body += f(threshold(v * 100)) + EQ_RAMP[pyround(v * 8)];
248
+ body += f(colorFor(colorSpec, v * 100) ?? OK) + EQ_RAMP[pyround(v * 8)];
215
249
  }
216
250
  return body + bgsgrBox(boxRgb);
217
251
  }
@@ -236,18 +270,13 @@ export function renderValue(entry, cells, boxRgb) {
236
270
  }
237
271
  if (render === "ammo") return label + rAmmo(val, color || "threshold");
238
272
  if (render === "spark") return label + rSpark(val, entry.spark_style || "block", boxRgb, entry.spark_max);
239
- if (render === "equalizer") return label + rEqualizer(val, boxRgb);
273
+ if (render === "equalizer") return label + rEqualizer(val, boxRgb, color || "threshold");
240
274
  if (render === "list") {
241
275
  const items = VALUES[entry.id] || [];
242
276
  return label + f(TEXT) + items.map((x) => (Array.isArray(x) ? `${x[0]} ${x[1]}` : String(x))).join(" ");
243
277
  }
244
278
  // number / text
245
- let col;
246
- if (color === "threshold") {
247
- col = threshold(parseInt(String(val).replace(/\D/g, "") || "0", 10));
248
- } else {
249
- col = color ? rgbOf(color) : TEXT;
250
- }
279
+ const col = colorFor(color, parseInt(String(val).replace(/\D/g, "") || "0", 10)) ?? TEXT;
251
280
  return label + f(col) + String(val);
252
281
  }
253
282
 
@@ -292,7 +321,7 @@ export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
292
321
  return lw + capLen(entry.group.filter((i) => i in VALUES).map((i) => String(VALUES[i])).join(sep), textCap) + rextra;
293
322
  }
294
323
  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);
324
+ if (r === "equalizer") return lw + Math.min((VALUES[entry.id] || []).length, EQ_MAX) + rextra;
296
325
  if (r === "ammo") return lw + 5 + vlen(" " + (entry.id in VALUES ? VALUES[entry.id] : 0) + "%");
297
326
  if (r === "list") {
298
327
  const items = VALUES[entry.id] || [];
@@ -564,7 +593,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0, overflow) {
564
593
  const tailW = vlen(tail);
565
594
  let body;
566
595
  if (Array.isArray(item)) { // [left, right] (agents)
567
- const right = f(TEXT) + String(item[1]) + (marker ? f(TEXT) + tail : "");
596
+ const right = f(TEXT) + String(item[1]) + (marker ? f(TITLE) + tail : "");
568
597
  const rightW = vlen(String(item[1])) + tailW;
569
598
  const labelMax = Math.max(0, w - vlen(lbl) - rightW - 1); // 1 = min gap
570
599
  const left = lbl + f(TEXT) + marquee(String(item[0]), labelMax, tick, ovf);
@@ -578,7 +607,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0, overflow) {
578
607
  const max = Math.max(0, w - vlen(mPad) - 1 - tailW); // reserve gap + marker on the right
579
608
  body = head + marquee(String(item.text), max, tick, ovf);
580
609
  body += " ".repeat(Math.max(0, w - tailW - vlen(body)));
581
- if (tail) body += f(TEXT) + tail;
610
+ if (tail) body += f(TITLE) + tail;
582
611
  }
583
612
  col.push(bgsgrBox(boxRgb) + " " + body + " " + RESET);
584
613
  });
@@ -597,9 +626,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0, overflow) {
597
626
  const budget = w - vlen(lbl) - vlen(rhs);
598
627
  const sliceable = !ESC_RE.test(raw) || OSC8_RE.test(raw); // plain or hyperlink
599
628
  if (sliceable && budget > 0 && vlen(raw) > budget) {
600
- let col;
601
- if (m.color === "threshold") col = threshold(parseInt(raw.replace(/\D/g, "") || "0", 10));
602
- else col = m.color ? rgbOf(m.color) : TEXT;
629
+ const col = colorFor(m.color, parseInt(raw.replace(/\D/g, "") || "0", 10)) ?? TEXT;
603
630
  body = lbl + f(col) + marquee(raw, budget, tick, ovf);
604
631
  }
605
632
  }
package/src/statusline.js CHANGED
@@ -239,6 +239,16 @@ function ramPercent() {
239
239
  try { return pyround((1 - os.freemem() / os.totalmem()) * 100); } catch { return null; }
240
240
  }
241
241
 
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
+
242
252
  // Per-core idle fraction over a cumulative-time delta; null when no time elapsed.
243
253
  const cpuUtil = (dt, di) => (dt > 0 ? Math.max(0, Math.min(1, 1 - di / dt)) : null);
244
254
 
@@ -268,12 +278,21 @@ function cpuMetrics() {
268
278
  });
269
279
  } catch { return { cpu: null, cores: null }; }
270
280
  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 };
281
+ const now = Date.now();
282
+ const cur = { ts: now, total: agg.total, idle: agg.idle, cores };
272
283
  const cache = path.join(TMP, "mugshot_cpu.json");
273
284
  let prev = null;
274
285
  try { prev = JSON.parse(readFileSync(cache, "utf8")); } catch { /* none */ }
275
- try { writeFileSync(cache, JSON.stringify(cur)); } catch { /* ignore */ }
276
- return cpuDeltas(prev, cur);
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;
277
296
  }
278
297
 
279
298
  function sysValues(cwd) {