claude-doom-statusbar 0.8.0 → 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 +6 -0
- package/package.json +1 -1
- package/presets/full.toml +1 -1
- package/src/render.js +45 -18
- package/src/statusline.js +22 -3
package/README.md
CHANGED
|
@@ -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.
|
|
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": {
|
package/presets/full.toml
CHANGED
|
@@ -88,7 +88,7 @@ type = "box"
|
|
|
88
88
|
title = "SYS"
|
|
89
89
|
metric = [
|
|
90
90
|
{ id = "sys.cpu", render = "number", icon = "🔥" },
|
|
91
|
-
{ id = "sys.cores", render = "equalizer", icon = "
|
|
91
|
+
{ id = "sys.cores", render = "equalizer", icon = "📊", color = "threshold" },
|
|
92
92
|
{ id = "sys.disk", render = "bar", icon = "💿", color = "threshold" },
|
|
93
93
|
{ id = "sys.session", render = "text", icon = "🕙" },
|
|
94
94
|
{ id = "sys.clock", render = "text", icon = "🕓" },
|
package/src/render.js
CHANGED
|
@@ -90,8 +90,42 @@ function headCols(text, width) {
|
|
|
90
90
|
return out;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
|
207
|
-
// span-normalize), 9-level EQ_RAMP. Fixed-width:
|
|
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(
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
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) {
|