claude-doom-statusbar 0.7.0 → 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.0",
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
@@ -1,4 +1,7 @@
1
1
  # full — DOOM panel, everything on
2
+ # text_overflow = "scroll" # how labels longer than their box behave: "scroll" (ping-pong
3
+ # marquee, default) or "clip" (static, truncated with …).
4
+ # Env override: DOOMBAR_TEXT_OVERFLOW.
2
5
  [bar]
3
6
  border_style = "vertical"
4
7
  border_color = "term-bg" # seamless cuts through the panel
@@ -84,7 +87,8 @@ metric = [
84
87
  type = "box"
85
88
  title = "SYS"
86
89
  metric = [
87
- { id = "sys.cpu", render = "number", icon = "🔥" },
90
+ { id = "sys.cpu", render = "number", icon = "🔥" },
91
+ { id = "sys.cores", render = "equalizer", icon = "🎚", color = "threshold" },
88
92
  { id = "sys.disk", render = "bar", icon = "💿", color = "threshold" },
89
93
  { id = "sys.session", render = "text", icon = "🕙" },
90
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
 
@@ -66,6 +68,28 @@ export function vlen(s) {
66
68
  return n;
67
69
  }
68
70
 
71
+ // OSC8 hyperlink helpers — long hyperlinked labels (cwd, branch) can't be column-sliced
72
+ // as-is (slicing corrupts the escape), so we operate on the visible text and re-wrap it
73
+ // with the same URL. Matches the format emitted by statusline's _link().
74
+ const OSC8_RE = /^\x1b\]8;;([^\x1b\x07]*)(?:\x1b\\|\x07)([\s\S]*?)\x1b\]8;;(?:\x1b\\|\x07)$/;
75
+ function splitLink(s) {
76
+ const m = String(s).match(OSC8_RE);
77
+ return m ? { url: m[1], inner: m[2] } : null;
78
+ }
79
+ function wrapLink(text, url) {
80
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
81
+ }
82
+ // First `width` visible columns of `text`, no padding, never splitting a 2-col glyph.
83
+ function headCols(text, width) {
84
+ let col = 0, out = "";
85
+ for (const ch of [...String(text)]) {
86
+ const cw = vlen(ch);
87
+ if (col + cw > width) break;
88
+ out += ch; col += cw;
89
+ }
90
+ return out;
91
+ }
92
+
69
93
  function threshold(pct) {
70
94
  return pct < 60 ? OK : pct < 85 ? WARN : CRIT;
71
95
  }
@@ -125,6 +149,12 @@ const SPARK_BRAILLE = [
125
149
  "⠀⢀⢠⢰⢸", "⡀⣀⣠⣰⣸", "⡄⣄⣤⣴⣼", "⡆⣆⣦⣶⣾", "⡇⣇⣧⣷⣿",
126
150
  ].map((r) => [...r]);
127
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
128
158
 
129
159
  function rSpark(values, style = "block", boxRgb = TERM_RGB, vmax = null) {
130
160
  const empty = [0, 1, 2].map((i) => Math.floor((boxRgb[i] + TERM_RGB[i]) / 2));
@@ -156,6 +186,36 @@ function rSpark(values, style = "block", boxRgb = TERM_RGB, vmax = null) {
156
186
  return bg + f(SPARK) + body + bgsgrBox(boxRgb);
157
187
  }
158
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
+
159
219
  export function renderValue(entry, cells, boxRgb) {
160
220
  const icon = entry.icon || "";
161
221
  const label = icon ? icon + " " : "";
@@ -176,6 +236,7 @@ export function renderValue(entry, cells, boxRgb) {
176
236
  }
177
237
  if (render === "ammo") return label + rAmmo(val, color || "threshold");
178
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);
179
240
  if (render === "list") {
180
241
  const items = VALUES[entry.id] || [];
181
242
  return label + f(TEXT) + items.map((x) => (Array.isArray(x) ? `${x[0]} ${x[1]}` : String(x))).join(" ");
@@ -213,7 +274,11 @@ const ESC_RE = /\x1b/;
213
274
  function capLen(s, textCap) {
214
275
  const str = String(s);
215
276
  const w = vlen(str);
216
- return ESC_RE.test(str) ? w : Math.min(w, textCap);
277
+ // plain text and OSC8 hyperlinks are column-sliceable (marquee/clip operate on the
278
+ // visible text), so cap them to textCap. Other escapes (raw SGR we can't safely slice)
279
+ // keep their full width as a hard floor.
280
+ const sliceable = !ESC_RE.test(str) || OSC8_RE.test(str);
281
+ return sliceable ? Math.min(w, textCap) : w;
217
282
  }
218
283
 
219
284
  export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
@@ -227,6 +292,7 @@ export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
227
292
  return lw + capLen(entry.group.filter((i) => i in VALUES).map((i) => String(VALUES[i])).join(sep), textCap) + rextra;
228
293
  }
229
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);
230
296
  if (r === "ammo") return lw + 5 + vlen(" " + (entry.id in VALUES ? VALUES[entry.id] : 0) + "%");
231
297
  if (r === "list") {
232
298
  const items = VALUES[entry.id] || [];
@@ -304,11 +370,18 @@ function sliceCols(text, off, width) {
304
370
 
305
371
  // Fit `text` into exactly `width` display columns. Fits -> left-aligned + padded.
306
372
  // Overflows -> ping-pong marquee window for the current `tick`.
307
- export function marquee(text, width, tick = 0) {
373
+ export function marquee(text, width, tick = 0, mode = "scroll") {
308
374
  text = String(text);
309
375
  if (width <= 0) return "";
376
+ const link = splitLink(text); // hyperlink: fit the visible text, re-wrap
377
+ if (link) return wrapLink(marquee(link.inner, width, tick, mode), link.url);
310
378
  const tw = vlen(text);
311
379
  if (tw <= width) return text + " ".repeat(width - tw);
380
+ if (mode === "clip") { // static truncation with an ellipsis
381
+ if (width === 1) return "…";
382
+ const h = headCols(text, width - 1);
383
+ return h + "…" + " ".repeat(Math.max(0, width - vlen(h) - 1));
384
+ }
312
385
  return sliceCols(text, marqueeOffset(tw - width, tick), width);
313
386
  }
314
387
 
@@ -428,8 +501,10 @@ export function resolvePreset(chosenCfg, target, loadByName, spriteFor) {
428
501
  return last; // nothing fit -> smallest reached
429
502
  }
430
503
 
431
- export function buildBar(cfg, target, spriteFor, tick = 0) {
504
+ export function buildBar(cfg, target, spriteFor, tick = 0, overflow) {
432
505
  if (!spriteFor) spriteFor = (hp) => `STFST${hp}1`;
506
+ // How overflowing text fits its box: "scroll" (ping-pong marquee) or "clip" (static …).
507
+ const ovf = overflow || cfg.text_overflow || "scroll";
433
508
 
434
509
  const { style, headers, segs, totalRows, hp, face, faceW } = layoutContext(cfg, spriteFor);
435
510
  const bar = cfg.bar || {};
@@ -464,10 +539,10 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
464
539
  if (Array.isArray(item) && item.length === 2) {
465
540
  const right = f(TEXT) + String(item[1]);
466
541
  const budget = Math.max(0, w - vlen(lbl) - vlen(String(item[1])) - 1); // 1 = min gap
467
- const left = lbl + f(TEXT) + marquee(String(item[0]), budget, tick);
542
+ const left = lbl + f(TEXT) + marquee(String(item[0]), budget, tick, ovf);
468
543
  body = left + " ".repeat(Math.max(0, w - vlen(left) - vlen(String(item[1])))) + right;
469
544
  } else {
470
- body = lbl + f(TEXT) + marquee(String(item), Math.max(0, w - vlen(lbl)), tick);
545
+ body = lbl + f(TEXT) + marquee(String(item), Math.max(0, w - vlen(lbl)), tick, ovf);
471
546
  }
472
547
  col.push(bgsgrBox(boxRgb) + " " + body + " " + RESET);
473
548
  }
@@ -492,7 +567,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
492
567
  const right = f(TEXT) + String(item[1]) + (marker ? f(TEXT) + tail : "");
493
568
  const rightW = vlen(String(item[1])) + tailW;
494
569
  const labelMax = Math.max(0, w - vlen(lbl) - rightW - 1); // 1 = min gap
495
- const left = lbl + f(TEXT) + marquee(String(item[0]), labelMax, tick);
570
+ const left = lbl + f(TEXT) + marquee(String(item[0]), labelMax, tick, ovf);
496
571
  const room = Math.max(0, w - vlen(left) - rightW);
497
572
  body = left + " ".repeat(room) + right;
498
573
  } else { // {mark, markRgb, text} (tasks)
@@ -501,7 +576,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
501
576
  const mPad = m + (vlen(m) < 2 ? " " : ""); // normalize mark to 2 cols so text aligns
502
577
  const head = markCol + mPad + " " + f(TEXT);
503
578
  const max = Math.max(0, w - vlen(mPad) - 1 - tailW); // reserve gap + marker on the right
504
- body = head + marquee(String(item.text), max, tick);
579
+ body = head + marquee(String(item.text), max, tick, ovf);
505
580
  body += " ".repeat(Math.max(0, w - tailW - vlen(body)));
506
581
  if (tail) body += f(TEXT) + tail;
507
582
  }
@@ -520,11 +595,12 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
520
595
  const raw = String(VALUES[m.id]);
521
596
  const lbl = m.icon ? m.icon + " " : "";
522
597
  const budget = w - vlen(lbl) - vlen(rhs);
523
- if (!/[\x1b]/.test(raw) && budget > 0 && vlen(raw) > budget) {
598
+ const sliceable = !ESC_RE.test(raw) || OSC8_RE.test(raw); // plain or hyperlink
599
+ if (sliceable && budget > 0 && vlen(raw) > budget) {
524
600
  let col;
525
601
  if (m.color === "threshold") col = threshold(parseInt(raw.replace(/\D/g, "") || "0", 10));
526
602
  else col = m.color ? rgbOf(m.color) : TEXT;
527
- body = lbl + f(col) + marquee(raw, budget, tick);
603
+ body = lbl + f(col) + marquee(raw, budget, tick, ovf);
528
604
  }
529
605
  }
530
606
  body += " ".repeat(Math.max(0, w - vlen(body) - vlen(rhs))) + rhs;
package/src/statusline.js CHANGED
@@ -145,15 +145,17 @@ export function buildValues(data, git) {
145
145
  if (repo.host && repo.owner && repo.name) repoUrl = `https://${repo.host}/${repo.owner}/${repo.name}`;
146
146
 
147
147
  const sname = data.session_name || data.session_id; // session_name only set via /rename or --name
148
- if (sname) v["session.name"] = clip(sname, 24);
148
+ // Clip generously, not to box width: the renderer fits each field to its box (marquee or
149
+ // clip per text_overflow). A tight clip here would truncate before the renderer ever sees it.
150
+ if (sname) v["session.name"] = clip(sname, 60);
149
151
 
150
152
  const cwd = data.cwd || (data.workspace || {}).current_dir;
151
153
  if (cwd) {
152
- const name = clip(path.basename(cwd.replace(/[/\\]+$/, "")) || cwd, 24);
154
+ const name = clip(path.basename(cwd.replace(/[/\\]+$/, "")) || cwd, 60);
153
155
  try { v["loc.cwd"] = _link(name, pathToFileURL(cwd).href); } catch { v["loc.cwd"] = name; }
154
156
  // git fields come from the folded snapshot the async hook wrote, not a live spawn.
155
157
  const { br = null, lr = null, st = null } = git || {};
156
- if (br) { const brLbl = clip(br, 24); v["git.branch"] = repoUrl ? _link(brLbl, `${repoUrl}/tree/${br}`) : brLbl; }
158
+ if (br) { const brLbl = clip(br, 60); v["git.branch"] = repoUrl ? _link(brLbl, `${repoUrl}/tree/${br}`) : brLbl; }
157
159
  if (lr && lr.includes("\t")) {
158
160
  const [behind, ahead] = lr.split("\t");
159
161
  v["git.behind"] = `↓${behind}`; v["git.ahead"] = `↑${ahead}`;
@@ -237,26 +239,50 @@ function ramPercent() {
237
239
  try { return pyround((1 - os.freemem() / os.totalmem()) * 100); } catch { return null; }
238
240
  }
239
241
 
240
- function cpuPercent() {
241
- 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;
242
264
  try {
243
- for (const c of os.cpus()) { for (const k in c.times) total += c.times[k]; idle += c.times.idle; }
244
- } 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 };
245
272
  const cache = path.join(TMP, "mugshot_cpu.json");
246
273
  let prev = null;
247
274
  try { prev = JSON.parse(readFileSync(cache, "utf8")); } catch { /* none */ }
248
- try { writeFileSync(cache, JSON.stringify({ total, idle })); } catch { /* ignore */ }
249
- if (!prev) return null;
250
- const dt = total - prev.total, di = idle - prev.idle;
251
- 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);
252
277
  }
253
278
 
254
279
  function sysValues(cwd) {
255
280
  const v = {};
256
281
  const ram = ramPercent();
257
282
  if (ram !== null) v["sys.ram"] = ram;
258
- const cpu = cpuPercent();
283
+ const { cpu, cores } = cpuMetrics();
259
284
  if (cpu !== null) v["sys.cpu"] = `${cpu}%`;
285
+ if (cores) v["sys.cores"] = cores;
260
286
  try {
261
287
  const stat = statfsSync(cwd || process.cwd());
262
288
  v["sys.disk"] = pyround(((stat.blocks - stat.bfree) / stat.blocks) * 100);
@@ -471,7 +497,9 @@ function main() {
471
497
  catch { return null; }
472
498
  };
473
499
  const selected = resolvePreset(cfg, target, loadByName, spriteFor);
474
- const res = buildBar(selected, target, spriteFor, tick);
500
+ // Text overflow behavior: env wins, then the preset's text_overflow, default "scroll".
501
+ const overflow = process.env.DOOMBAR_TEXT_OVERFLOW || selected.text_overflow || cfg.text_overflow || "scroll";
502
+ const res = buildBar(selected, target, spriteFor, tick, overflow);
475
503
  process.stdout.write(res.lines.join("\n") + "\n");
476
504
  }
477
505