claude-doom-statusbar 0.4.0 → 0.6.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
@@ -38,7 +38,7 @@ npx claude-doom-statusbar install
38
38
  That writes the `statusLine`, the lifecycle hooks, and the preset into `~/.claude/settings.json` for you (merging into whatever's already there, with a one-level `.bak`). Restart Claude Code and the HUD is live.
39
39
 
40
40
  ```bash
41
- npx claude-doom-statusbar install --preset full # full | default | minimal (default: full)
41
+ npx claude-doom-statusbar install --preset full # full | standard | minimal (default: full)
42
42
  npx claude-doom-statusbar install --project # write ./.claude/settings.json instead of ~/.claude
43
43
  npx claude-doom-statusbar uninstall # remove everything the installer added
44
44
  ```
@@ -65,14 +65,18 @@ $env:FORCE_HYPERLINK = "1"; claude
65
65
 
66
66
  ## Presets
67
67
 
68
- `DOOMBAR_PRESET` picks the layout (defaults to `presets/default.toml`):
68
+ `DOOMBAR_PRESET` picks the layout (defaults to `presets/standard.toml`):
69
69
 
70
70
  - **`minimal`** — a couple of bars, blends into the terminal.
71
- - **`default`** — balanced HUD.
71
+ - **`standard`** — balanced HUD.
72
72
  - **`full`** — every box, the look in the screenshot above.
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`, `ammo`, `list`, `scroll`, or a `group`. Copy one and rearrange the boxes, swap icons, or change which metrics show.
75
75
 
76
+ ### Responsive width
77
+
78
+ 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.
79
+
76
80
  ## How it works
77
81
 
78
82
  - **`src/statusline.js`** is the statusLine command. Claude Code pipes session JSON on stdin; it maps that (plus git via shell, system metrics from Node built-ins, and the hook state file) to metric values, picks the mugshot sprite, and renders the preset.
package/bin/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // Port of install.py.
4
4
  //
5
5
  // claude-doom-statusbar install # install into ~/.claude/settings.json
6
- // claude-doom-statusbar install --preset full # pick a preset (full | default | minimal)
6
+ // claude-doom-statusbar install --preset full # pick a preset (full | standard | minimal)
7
7
  // claude-doom-statusbar install --project # install into ./.claude/settings.json instead
8
8
  // claude-doom-statusbar uninstall # remove everything this installer added
9
9
  //
@@ -124,7 +124,7 @@ function parseArgs(argv) {
124
124
  else if (a.startsWith("--preset=")) out.preset = a.slice("--preset=".length);
125
125
  else die(`! unknown argument: ${a}`);
126
126
  }
127
- if (out.cmd === "install" && !out.preset) die("! --preset needs a value (full | default | minimal)");
127
+ if (out.cmd === "install" && !out.preset) die("! --preset needs a value (full | standard | minimal)");
128
128
  return out;
129
129
  }
130
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-doom-statusbar",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
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",
19
19
  "preversion": "npm test",
20
20
  "postversion": "git push --follow-tags",
21
21
  "prepublishOnly": "npm test"
package/presets/full.toml CHANGED
@@ -4,6 +4,7 @@ border_style = "vertical"
4
4
  border_color = "term-bg" # seamless cuts through the panel
5
5
  box_background = "#1c2036"
6
6
  headers = true
7
+ fallback = "standard" # degrade to this preset when the terminal is too narrow
7
8
 
8
9
  [mugshot]
9
10
  background = "#000000"
@@ -66,6 +67,7 @@ metric = [
66
67
  [[segment]]
67
68
  type = "box"
68
69
  title = "AGENTS"
70
+ max_width = 22 # cap width so long agent labels marquee (scroll back and forth)
69
71
  metric = [
70
72
  { id = "act.subagents", render = "scroll", anchor = "top", icon = "👹" },
71
73
  ]
@@ -73,6 +75,7 @@ metric = [
73
75
  [[segment]]
74
76
  type = "box"
75
77
  title = "TASKS"
78
+ max_width = 22 # cap width so long task titles marquee (scroll back and forth)
76
79
  metric = [
77
80
  { id = "act.tasklist", render = "scroll", anchor = "boundary" },
78
81
  ]
@@ -1,19 +1,56 @@
1
- # minimal — blends into the terminal, smallest footprint
1
+ # minimal — MODEL, USAGE, mugshot, PROJECT, ACTIVITY
2
2
  [bar]
3
- border_style = "none"
4
- box_background = "term-bg"
5
- headers = false
3
+ border_style = "vertical"
4
+ border_color = "term-bg" # seamless cuts through the panel
5
+ box_background = "#1c2036"
6
+ headers = true
6
7
 
7
8
  [mugshot]
8
- background = "term-bg"
9
+ background = "#000000"
9
10
 
10
11
  [[segment]]
11
- type = "box"
12
+ type = "box"
13
+ title = "MODEL"
14
+ metric = [
15
+ { id = "model.name", render = "text", icon = "🤖", right = "model.effort" },
16
+ { id = "model.mode", render = "text" }, # 💭 thinking 🚀 fast
17
+ { id = "model.permission", render = "text" }, # 📋 plan / ⏩ auto (hidden on default)
18
+ { id = "model.style", render = "text", icon = "🎨" },
19
+ { id = "advisor.model", render = "text", icon = "🧙" },
20
+ ]
21
+
22
+ [[segment]]
23
+ type = "box"
24
+ title = "USAGE"
12
25
  metric = [
13
26
  { id = "context.hp", render = "bar", icon = "🧠", color = "threshold" },
14
- { id = "ratelimit.5h", render = "ammo", icon = "🕔" },
27
+ { id = "ratelimit.5h", render = "bar", icon = "🕔", color = "threshold", show_pct = false, suffix = "usage.reset5h" },
28
+ { id = "ratelimit.7d", render = "bar", icon = "📅", color = "threshold", show_pct = false, suffix = "usage.reset7d" },
29
+ { id = "sys.ram", render = "bar", icon = "💾", color = "threshold" },
15
30
  { id = "cost.total", render = "number", icon = "💰" },
16
31
  ]
17
32
 
18
33
  [[segment]]
19
34
  type = "mugshot"
35
+
36
+ [[segment]]
37
+ type = "box"
38
+ title = "PROJECT"
39
+ metric = [
40
+ { id = "session.name", render = "text", icon = "🎮" },
41
+ { id = "loc.cwd", render = "text", icon = "📁" },
42
+ { id = "git.branch", render = "text", icon = "🌿" },
43
+ { id = "git.work", render = "text" }, # ✎ files ⇅ pull/push
44
+ { id = "loc.churn", render = "text", icon = "📝" },
45
+ { id = "pr.state", render = "text", icon = "⇧ " },
46
+ ]
47
+
48
+ [[segment]]
49
+ type = "box"
50
+ title = "ACTIVITY"
51
+ metric = [
52
+ { id = "act.geiger", render = "spark", icon = "📟", spark_style = "octant", spark_max = 1 },
53
+ { id = "act.agents", render = "number", icon = "👹" },
54
+ { id = "act.tasks", render = "number", icon = "🎯" },
55
+ { id = "act.errors", render = "number", icon = "💢", color = "threshold" },
56
+ ]
@@ -0,0 +1,73 @@
1
+ # standard — full minus SAVE and SYS
2
+ [bar]
3
+ border_style = "vertical"
4
+ border_color = "term-bg" # seamless cuts through the panel
5
+ box_background = "#1c2036"
6
+ headers = true
7
+ fallback = "minimal" # degrade to this preset when the terminal is too narrow
8
+
9
+ [mugshot]
10
+ background = "#000000"
11
+
12
+ [[segment]]
13
+ type = "box"
14
+ title = "MODEL"
15
+ metric = [
16
+ { id = "model.name", render = "text", icon = "🤖", right = "model.effort" },
17
+ { id = "model.mode", render = "text" }, # 💭 thinking 🚀 fast
18
+ { id = "model.permission", render = "text" }, # 📋 plan / ⏩ auto (hidden on default)
19
+ { id = "model.style", render = "text", icon = "🎨" },
20
+ { id = "advisor.model", render = "text", icon = "🧙" },
21
+ ]
22
+
23
+ [[segment]]
24
+ type = "box"
25
+ title = "USAGE"
26
+ metric = [
27
+ { id = "context.hp", render = "bar", icon = "🧠", color = "threshold" },
28
+ { id = "ratelimit.5h", render = "bar", icon = "🕔", color = "threshold", show_pct = false, suffix = "usage.reset5h" },
29
+ { id = "ratelimit.7d", render = "bar", icon = "📅", color = "threshold", show_pct = false, suffix = "usage.reset7d" },
30
+ { id = "sys.ram", render = "bar", icon = "💾", color = "threshold" },
31
+ { id = "cost.total", render = "number", icon = "💰" },
32
+ ]
33
+
34
+ [[segment]]
35
+ type = "box"
36
+ title = "PROJECT"
37
+ metric = [
38
+ { id = "session.name", render = "text", icon = "🎮" },
39
+ { id = "loc.cwd", render = "text", icon = "📁" },
40
+ { id = "git.branch", render = "text", icon = "🌿" },
41
+ { id = "git.work", render = "text" }, # ✎ files ⇅ pull/push
42
+ { id = "loc.churn", render = "text", icon = "📝" },
43
+ { id = "pr.state", render = "text", icon = "⇧ " },
44
+ ]
45
+
46
+ [[segment]]
47
+ type = "mugshot"
48
+
49
+ [[segment]]
50
+ type = "box"
51
+ title = "ACTIVITY"
52
+ metric = [
53
+ { id = "act.geiger", render = "spark", icon = "📟", spark_style = "octant", spark_max = 1 },
54
+ { id = "act.agents", render = "number", icon = "👹" },
55
+ { id = "act.tasks", render = "number", icon = "🎯" },
56
+ { id = "act.errors", render = "number", icon = "💢", color = "threshold" },
57
+ ]
58
+
59
+ [[segment]]
60
+ type = "box"
61
+ title = "AGENTS"
62
+ max_width = 22 # cap width so long agent labels marquee (scroll back and forth)
63
+ metric = [
64
+ { id = "act.subagents", render = "scroll", anchor = "top", icon = "👹" },
65
+ ]
66
+
67
+ [[segment]]
68
+ type = "box"
69
+ title = "TASKS"
70
+ max_width = 22 # cap width so long task titles marquee (scroll back and forth)
71
+ metric = [
72
+ { id = "act.tasklist", render = "scroll", anchor = "boundary" },
73
+ ]
package/src/render.js CHANGED
@@ -203,7 +203,20 @@ function barMeta(entry) {
203
203
  return [lw, 0, entry.render || "text"];
204
204
  }
205
205
 
206
- export function metricFixedWidth(entry) {
206
+ // Display width of a plain text run, capped at `textCap` columns — but only when
207
+ // it is marquee-safe. Values carrying ANSI/OSC escapes (coloured text, hyperlinks)
208
+ // can't be column-sliced without corrupting the escape, so they keep full width
209
+ // (a hard floor): their box shrinks less, which is what trips the preset fallback.
210
+ const TEXTCAP_MAX = 24; // upper bound — matches statusline's clip(…, 24)
211
+ const TEXTCAP_MIN = 10; // lower bound — the readable floor before falling back
212
+ const ESC_RE = /\x1b/;
213
+ function capLen(s, textCap) {
214
+ const str = String(s);
215
+ const w = vlen(str);
216
+ return ESC_RE.test(str) ? w : Math.min(w, textCap);
217
+ }
218
+
219
+ export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
207
220
  const icon = entry.icon || "";
208
221
  const lw = vlen(icon ? icon + " " : "");
209
222
  const r = entry.render || "text";
@@ -211,7 +224,7 @@ export function metricFixedWidth(entry) {
211
224
  const rextra = rid && rid in VALUES ? 1 + vlen(String(VALUES[rid])) : 0;
212
225
  if ("group" in entry) {
213
226
  const sep = entry.sep ?? " ";
214
- return lw + vlen(entry.group.filter((i) => i in VALUES).map((i) => String(VALUES[i])).join(sep)) + rextra;
227
+ return lw + capLen(entry.group.filter((i) => i in VALUES).map((i) => String(VALUES[i])).join(sep), textCap) + rextra;
215
228
  }
216
229
  if (r === "spark") return lw + Math.floor(((VALUES[entry.id] || []).length + 1) / 2);
217
230
  if (r === "ammo") return lw + 5 + vlen(" " + (entry.id in VALUES ? VALUES[entry.id] : 0) + "%");
@@ -220,21 +233,27 @@ export function metricFixedWidth(entry) {
220
233
  if (items.length === 0) return lw;
221
234
  return Math.max(...items.map((it) =>
222
235
  Array.isArray(it) && it.length === 2
223
- ? lw + vlen(String(it[0])) + 1 + vlen(String(it[1]))
224
- : lw + vlen(String(it))));
236
+ ? lw + capLen(it[0], textCap) + 1 + vlen(String(it[1]))
237
+ : lw + capLen(it, textCap)));
225
238
  }
226
239
  if (r === "scroll") {
227
240
  const items = VALUES[entry.id] || [];
228
241
  if (items.length === 0) return lw;
229
242
  return Math.max(...items.map((it) => {
230
243
  if (Array.isArray(it) && it.length === 2)
231
- return lw + vlen(String(it[0])) + 1 + vlen(String(it[1]));
244
+ return lw + capLen(it[0], textCap) + 1 + vlen(String(it[1]));
232
245
  // object {mark, text}
233
- return lw + vlen(String(it.mark || "")) + 1 + vlen(String(it.text || ""));
246
+ return lw + vlen(String(it.mark || "")) + 1 + capLen(it.text || "", textCap);
234
247
  }));
235
248
  }
236
249
  if (r === "bar") return null;
237
- return lw + vlen(String(entry.id in VALUES ? VALUES[entry.id] : "?")) + rextra;
250
+ return lw + capLen(entry.id in VALUES ? VALUES[entry.id] : "?", textCap) + rextra;
251
+ }
252
+
253
+ // The coupled text cap for a given bar-cell count: cells 14 -> cap 24, cells 4 ->
254
+ // cap 10, linearly in between. One scale drives bars and text together (approach A).
255
+ export function textCapFor(cells) {
256
+ return Math.round(TEXTCAP_MIN + (cells - 4) / (14 - 4) * (TEXTCAP_MAX - TEXTCAP_MIN));
238
257
  }
239
258
 
240
259
  export function scrollWindow(n, h, anchor, boundary) {
@@ -246,16 +265,63 @@ export function scrollWindow(n, h, anchor, boundary) {
246
265
  return { start, up: start, down: n - start - h };
247
266
  }
248
267
 
268
+ // --- horizontal marquee (the "car radio" scroll) ----------------------------
269
+ // Long text that won't fit its column budget glides left until its tail shows,
270
+ // pauses, then glides back to the start and pauses again — ping-pong, driven by
271
+ // the same per-refresh `tick` as the mugshot/geiger. Pure function of `tick`
272
+ // (no Date in here) so renders stay deterministic and testable.
273
+ const MARQUEE_STEP = 1; // display columns advanced per tick
274
+ const MARQUEE_DWELL = 3; // ticks held at each end before reversing
275
+
276
+ // Triangular offset wave 0..span..0 with a dwell at both extremes.
277
+ function marqueeOffset(span, tick) {
278
+ if (span <= 0) return 0;
279
+ const sweep = Math.ceil(span / MARQUEE_STEP);
280
+ const cycle = 2 * (MARQUEE_DWELL + sweep);
281
+ let t = ((tick % cycle) + cycle) % cycle;
282
+ if (t < MARQUEE_DWELL) return 0; // hold at start
283
+ t -= MARQUEE_DWELL;
284
+ if (t < sweep) return Math.min(span, t * MARQUEE_STEP); // glide forward 0->span
285
+ t -= sweep;
286
+ if (t < MARQUEE_DWELL) return span; // hold at end
287
+ t -= MARQUEE_DWELL;
288
+ return Math.max(0, span - t * MARQUEE_STEP); // glide back span->0
289
+ }
290
+
291
+ // Take a `width`-wide display window starting `off` columns in, never splitting a
292
+ // 2-col glyph; the result is always exactly `width` columns (padded with spaces).
293
+ function sliceCols(text, off, width) {
294
+ let col = 0, taken = 0, out = "";
295
+ for (const ch of [...String(text)]) {
296
+ const cw = vlen(ch);
297
+ if (col < off) { col += cw; continue; } // still left of the window
298
+ if (taken + cw > width) break; // glyph would overflow the window
299
+ out += ch; taken += cw; col += cw;
300
+ }
301
+ if (taken < width) out += " ".repeat(width - taken);
302
+ return out;
303
+ }
304
+
305
+ // Fit `text` into exactly `width` display columns. Fits -> left-aligned + padded.
306
+ // Overflows -> ping-pong marquee window for the current `tick`.
307
+ export function marquee(text, width, tick = 0) {
308
+ text = String(text);
309
+ if (width <= 0) return "";
310
+ const tw = vlen(text);
311
+ if (tw <= width) return text + " ".repeat(width - tw);
312
+ return sliceCols(text, marqueeOffset(tw - width, tick), width);
313
+ }
314
+
249
315
  function available(entry) {
250
316
  if ("group" in entry) return entry.group.some((i) => i in VALUES);
251
317
  if (entry.render === "list") return true;
252
318
  return entry.id in VALUES;
253
319
  }
254
320
 
255
- function boxWidth(box, cells) {
321
+ function boxWidth(box, cells, textCap = TEXTCAP_MAX) {
256
322
  const widths = [vlen(box.title || "")];
257
323
  for (const m of box.metric) {
258
- let fw = metricFixedWidth(m);
324
+ let fw = metricFixedWidth(m, textCap);
259
325
  if (fw === null) {
260
326
  const [lw, sw] = barMeta(m);
261
327
  fw = lw + cells + sw;
@@ -280,17 +346,15 @@ function hpRow(thresholds = HP_THRESHOLDS) {
280
346
  return thresholds.filter((t) => headroom < t).length;
281
347
  }
282
348
 
283
- export function buildBar(cfg, target, spriteFor) {
349
+ // Width-relevant layout context shared by buildBar (render) and planLayout (fit
350
+ // test). Filters unavailable metrics, computes the row count, and loads the mugshot
351
+ // art so its width counts toward the layout. spriteFor defaults to the idle face;
352
+ // the exact sprite never changes the mugshot's column width.
353
+ function layoutContext(cfg, spriteFor) {
284
354
  if (!spriteFor) spriteFor = (hp) => `STFST${hp}1`;
285
-
286
355
  const bar = cfg.bar || {};
287
356
  const style = bar.border_style ?? "vertical";
288
357
  const headers = (bar.headers ?? true) && style !== "frame";
289
- const boxRgb = rgbOf(bar.box_background ?? "term-bg");
290
- const bcol = bar.border_color ?? "term-fg";
291
- const mugRgb = rgbOf((cfg.mugshot || {}).background ?? "#000000");
292
-
293
- // availability: drop metrics whose value is absent; collapse empty boxes.
294
358
  const segs = [];
295
359
  for (const s of cfg.segment) {
296
360
  if (s.type === "mugshot") { segs.push(s); continue; }
@@ -301,35 +365,79 @@ export function buildBar(cfg, target, spriteFor) {
301
365
  const rowcount = (b) => b.metric.reduce((n, m) =>
302
366
  n + (m.render === "list" ? (VALUES[m.id] || []).length : (m.render === "scroll" ? 0 : 1)), 0);
303
367
  const dataRows = boxes.length ? Math.max(...boxes.map(rowcount)) : 0;
304
- const headersExtra = headers ? 1 : 0;
305
- const totalRows = Math.max(dataRows + headersExtra, 4); // 4 = mugshot floor
306
-
368
+ const totalRows = Math.max(dataRows + (headers ? 1 : 0), 4); // 4 = mugshot floor
307
369
  const hp = hpRow();
308
370
  const face = loadFace(spriteFor(hp), totalRows);
309
371
  const faceW = Math.max(...face.map((r) => r.length));
372
+ return { bar, style, headers, segs, totalRows, hp, face, faceW };
373
+ }
310
374
 
311
- const colWidths = (cells) => {
312
- const ws = [];
313
- let mug = null;
314
- segs.forEach((s, i) => {
315
- if (s.type === "mugshot") { ws.push(faceW + 2); mug = i; }
316
- else ws.push(boxWidth(s, cells) + 2);
317
- });
318
- return [ws, mug];
319
- };
320
-
321
- const balancedWidth = (cells) => {
322
- const [ws, mug] = colWidths(cells);
323
- if (mug === null) return ws.reduce((a, b) => a + b, 0) + (ws.length - 1);
324
- const left = ws.slice(0, mug).reduce((a, b) => a + b, 0) + mug;
325
- const right = ws.slice(mug + 1).reduce((a, b) => a + b, 0) + (ws.length - 1 - mug);
326
- return 2 * Math.max(left, right) + ws[mug];
327
- };
328
-
329
- let cells = 4;
375
+ function colWidthsOf(segs, faceW, cells, textCap) {
376
+ const ws = [];
377
+ let mug = null;
378
+ segs.forEach((s, i) => {
379
+ if (s.type === "mugshot") { ws.push(faceW + 2); mug = i; }
380
+ else ws.push(boxWidth(s, cells, textCap) + 2);
381
+ });
382
+ return [ws, mug];
383
+ }
384
+
385
+ function balancedWidthOf(segs, faceW, cells, textCap) {
386
+ const [ws, mug] = colWidthsOf(segs, faceW, cells, textCap);
387
+ if (mug === null) return ws.reduce((a, b) => a + b, 0) + (ws.length - 1);
388
+ const left = ws.slice(0, mug).reduce((a, b) => a + b, 0) + mug;
389
+ const right = ws.slice(mug + 1).reduce((a, b) => a + b, 0) + (ws.length - 1 - mug);
390
+ return 2 * Math.max(left, right) + ws[mug];
391
+ }
392
+
393
+ // Largest lockstep scale (bars 14->4, text 24->10) whose balanced layout fits
394
+ // `target`. Returns the minimum scale with fits=false when nothing fits — the
395
+ // caller (statusline) reads `fits` to decide whether to fall back to a smaller
396
+ // preset. Pure: no filesystem, deterministic for a given cfg + VALUES.
397
+ export function planLayout(cfg, target, spriteFor) {
398
+ const { segs, faceW } = layoutContext(cfg, spriteFor);
330
399
  for (let c = 14; c >= 4; c--) {
331
- if (balancedWidth(c) <= target) { cells = c; break; }
400
+ const textCap = textCapFor(c);
401
+ const width = balancedWidthOf(segs, faceW, c, textCap);
402
+ if (width <= target) return { cells: c, textCap, width, fits: true };
332
403
  }
404
+ const textCap = textCapFor(4);
405
+ return { cells: 4, textCap, width: balancedWidthOf(segs, faceW, 4, textCap), fits: false };
406
+ }
407
+
408
+ // Walk the per-preset fallback chain from `chosenCfg` (the ceiling) downward and
409
+ // return the first preset whose layout fits `target`; if none fit, return the last
410
+ // (smallest) one reached. `loadByName(name) -> cfg | null` loads a sibling preset;
411
+ // returning null (missing/unreadable) ends the chain. Stateless: ceiling + recovery
412
+ // fall out of re-deriving from `target` each call. Guards against fallback cycles.
413
+ export function resolvePreset(chosenCfg, target, loadByName, spriteFor) {
414
+ let cfg = chosenCfg, last = chosenCfg;
415
+ const seen = new Set();
416
+ while (cfg) {
417
+ last = cfg;
418
+ // Fit-test with the SAME sprite buildBar will render, so the mugshot column
419
+ // width matches: plan.fits then implies the rendered layout actually fits.
420
+ if (planLayout(cfg, target, spriteFor).fits) return cfg;
421
+ const next = cfg.bar && cfg.bar.fallback;
422
+ if (!next || seen.has(next)) break; // terminus or cycle
423
+ seen.add(next);
424
+ const loaded = loadByName(next);
425
+ if (!loaded) break; // missing/unreadable fallback
426
+ cfg = loaded;
427
+ }
428
+ return last; // nothing fit -> smallest reached
429
+ }
430
+
431
+ export function buildBar(cfg, target, spriteFor, tick = 0) {
432
+ if (!spriteFor) spriteFor = (hp) => `STFST${hp}1`;
433
+
434
+ const { style, headers, segs, totalRows, hp, face, faceW } = layoutContext(cfg, spriteFor);
435
+ const bar = cfg.bar || {};
436
+ const boxRgb = rgbOf(bar.box_background ?? "term-bg");
437
+ const bcol = bar.border_color ?? "term-fg";
438
+ const mugRgb = rgbOf((cfg.mugshot || {}).background ?? "#000000");
439
+
440
+ const { cells, textCap } = planLayout(cfg, target, spriteFor);
333
441
 
334
442
  const columns = [];
335
443
  let mugIdx = null;
@@ -339,7 +447,7 @@ export function buildBar(cfg, target, spriteFor) {
339
447
  columns.push(Array.from({ length: totalRows }, (_, r) => faceCell(face[r], faceW, mugRgb)));
340
448
  continue;
341
449
  }
342
- const w = boxWidth(s, cells);
450
+ const w = boxWidth(s, cells, textCap);
343
451
  const col = [];
344
452
  if (headers) {
345
453
  const t = s.title || "";
@@ -354,12 +462,12 @@ export function buildBar(cfg, target, spriteFor) {
354
462
  for (const item of VALUES[m.id] || []) {
355
463
  let body;
356
464
  if (Array.isArray(item) && item.length === 2) {
357
- const left = lbl + f(TEXT) + String(item[0]);
358
465
  const right = f(TEXT) + String(item[1]);
359
- body = left + " ".repeat(Math.max(0, w - vlen(left) - vlen(right))) + right;
466
+ 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);
468
+ body = left + " ".repeat(Math.max(0, w - vlen(left) - vlen(String(item[1])))) + right;
360
469
  } else {
361
- body = lbl + f(TEXT) + String(item);
362
- body += " ".repeat(Math.max(0, w - vlen(body)));
470
+ body = lbl + f(TEXT) + marquee(String(item), Math.max(0, w - vlen(lbl)), tick);
363
471
  }
364
472
  col.push(bgsgrBox(boxRgb) + " " + body + " " + RESET);
365
473
  }
@@ -384,20 +492,16 @@ export function buildBar(cfg, target, spriteFor) {
384
492
  const right = f(TEXT) + String(item[1]) + (marker ? f(TEXT) + tail : "");
385
493
  const rightW = vlen(String(item[1])) + tailW;
386
494
  const labelMax = Math.max(0, w - vlen(lbl) - rightW - 1); // 1 = min gap
387
- let label = String(item[0]);
388
- if (vlen(label) > labelMax) label = [...label].slice(0, Math.max(0, labelMax - 1)).join("") + "…";
389
- const left = lbl + f(TEXT) + label;
495
+ const left = lbl + f(TEXT) + marquee(String(item[0]), labelMax, tick);
390
496
  const room = Math.max(0, w - vlen(left) - rightW);
391
497
  body = left + " ".repeat(room) + right;
392
498
  } else { // {mark, markRgb, text} (tasks)
393
499
  const markCol = item.markRgb ? f(item.markRgb) : f(TEXT);
394
500
  const m = String(item.mark);
395
501
  const mPad = m + (vlen(m) < 2 ? " " : ""); // normalize mark to 2 cols so text aligns
396
- let text = String(item.text);
397
502
  const head = markCol + mPad + " " + f(TEXT);
398
- const max = w - vlen(mPad) - 1 - tailW; // reserve gap + marker on the right
399
- if (vlen(text) > max) text = [...text].slice(0, Math.max(0, max - 1)).join("") + "…";
400
- body = head + text;
503
+ 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);
401
505
  body += " ".repeat(Math.max(0, w - tailW - vlen(body)));
402
506
  if (tail) body += f(TEXT) + tail;
403
507
  }
@@ -408,6 +512,21 @@ export function buildBar(cfg, target, spriteFor) {
408
512
  let body = renderValue(m, m.render === "bar" ? cells : 0, boxRgb);
409
513
  const rid = m.right;
410
514
  const rhs = rid && rid in VALUES ? f(TEXT) + String(VALUES[rid]) : "";
515
+ // Plain text/number that overflows its column budget -> marquee. Skipped when
516
+ // the value carries ANSI/OSC escapes (colours, hyperlinks): those can't be
517
+ // sliced by column without corrupting the escape sequence.
518
+ const r = m.render || "text";
519
+ if ((r === "text" || r === "number") && !("group" in m) && m.id in VALUES) {
520
+ const raw = String(VALUES[m.id]);
521
+ const lbl = m.icon ? m.icon + " " : "";
522
+ const budget = w - vlen(lbl) - vlen(rhs);
523
+ if (!/[\x1b]/.test(raw) && budget > 0 && vlen(raw) > budget) {
524
+ let col;
525
+ if (m.color === "threshold") col = threshold(parseInt(raw.replace(/\D/g, "") || "0", 10));
526
+ else col = m.color ? rgbOf(m.color) : TEXT;
527
+ body = lbl + f(col) + marquee(raw, budget, tick);
528
+ }
529
+ }
411
530
  body += " ".repeat(Math.max(0, w - vlen(body) - vlen(rhs))) + rhs;
412
531
  col.push(bgsgrBox(boxRgb) + " " + body + " " + RESET);
413
532
  }
@@ -456,11 +575,12 @@ function main() {
456
575
  p = path.isAbsolute(arg) ? arg : path.join(process.cwd(), arg);
457
576
  try { readFileSync(p); } catch { p = path.join(REPO, "presets", path.basename(arg)); }
458
577
  } else {
459
- p = path.join(REPO, "presets", "default.toml");
578
+ p = path.join(REPO, "presets", "standard.toml");
460
579
  }
461
580
  const target = process.argv[3] ? parseInt(process.argv[3], 10) : 100;
581
+ const tick = process.argv[4] ? parseInt(process.argv[4], 10) : 0; // marquee phase for previews
462
582
  const cfg = parseToml(readFileSync(p, "utf8"));
463
- const res = buildBar(cfg, target);
583
+ const res = buildBar(cfg, target, undefined, tick);
464
584
  const out = ["", ` preset: ${path.basename(p)} style=${res.style} headers=${res.headers} bar=${res.cells}`, ""];
465
585
  out.push(...res.lines, "");
466
586
  process.stdout.write(out.join("\n") + "\n");
package/src/statusline.js CHANGED
@@ -7,10 +7,10 @@
7
7
  // settings.json:
8
8
  // "statusLine": { "type": "command",
9
9
  // "command": "node /abs/path/src/statusline.js", "refreshInterval": 1 }
10
- // Config: $DOOMBAR_PRESET (default presets/default.toml) State: $MUGSHOT_STATE
10
+ // Config: $DOOMBAR_PRESET (default presets/standard.toml) State: $MUGSHOT_STATE
11
11
 
12
12
  import {
13
- readFileSync, writeFileSync, openSync, fstatSync, readSync, closeSync, statfsSync,
13
+ readFileSync, writeFileSync, openSync, fstatSync, readSync, closeSync, statfsSync, statSync,
14
14
  } from "node:fs";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
@@ -18,7 +18,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { spawnSync } from "node:child_process";
19
19
  import { parse as parseToml } from "smol-toml";
20
20
  import { pyround, sgrFg } from "./ansi.js";
21
- import { buildBar, setValues, OK, TEXT, CRIT } from "./render.js";
21
+ import { buildBar, setValues, resolvePreset, OK, TEXT, CRIT } from "./render.js";
22
22
 
23
23
  const HERE = path.dirname(fileURLToPath(import.meta.url));
24
24
  const REPO = path.dirname(HERE);
@@ -232,10 +232,10 @@ function sysValues(cwd) {
232
232
  return v;
233
233
  }
234
234
 
235
- // Token-savings rows read from the small JSON files context-optimization tools already
236
- // persist. No plugin patching, no binary spawn just a cheap read each refresh. Paths are
237
- // env-overridable (DOOMBAR_*) so tests can point at fixtures, mirroring statePath/MUGSHOT_STATE.
238
- const leanCtxPath = () => process.env.DOOMBAR_LEANCTX || path.join(os.homedir(), ".lean-ctx", "mcp-live.json");
235
+ // --- Token-savings rows -----------------------------------------------------
236
+ // Read from what context-optimization tools already persist on disk. No plugin
237
+ // patching, no binary spawn. Paths are env-overridable (DOOMBAR_*) for tests.
238
+ const eventsPath = () => process.env.DOOMBAR_EVENTS || path.join(os.homedir(), ".lean-ctx", "events.jsonl");
239
239
  const llmlinguaPath = () => process.env.DOOMBAR_LLMLINGUA || path.join(os.homedir(), ".llmlingua-stats.json");
240
240
 
241
241
  // Defensive read: missing file or malformed JSON -> null (the row simply never appears).
@@ -243,46 +243,95 @@ function readJson(p) {
243
243
  try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
244
244
  }
245
245
 
246
- // One entry per savings source; extract returns the display string or null (omit the row).
247
- // Adding a source later is one entry here plus one preset line not an adapter framework.
248
- const SAVINGS_SOURCES = [
249
- {
250
- key: "save.leanctx",
251
- path: leanCtxPath,
252
- extract: (d) => {
253
- if (!(d.tokens_saved > 0)) return null;
254
- // compression_rate is a 0-100 percentage (verified against historical data).
255
- // Round it a fractional value would render "63.45%" and shift the box width.
256
- return typeof d.compression_rate === "number"
257
- ? `${k(d.tokens_saved)} ${Math.round(d.compression_rate)}%`
258
- : k(d.tokens_saved);
259
- },
260
- },
261
- {
262
- key: "save.lingua",
263
- path: llmlinguaPath,
264
- extract: (d) => {
265
- // Prefer the nested session schema (smart-read). The flat lifetime-only shape
266
- // (llmlingua_logged.py: tokens_saved_total, no session) is absent for the session view.
267
- const s = d.session;
268
- if (!s || !(s.tokens_saved > 0)) return null;
269
- // Round/clamp the secondary figure so a many-decimal value can't shift box width.
270
- if (s.last_saved_pct != null) return `${k(s.tokens_saved)} ${Math.round(s.last_saved_pct)}%`;
271
- // No original-token count in the session block, so a percent isn't derivable; show the ratio.
272
- if (s.last_ratio != null) return `${k(s.tokens_saved)} ${Number(s.last_ratio).toFixed(1)}x`;
273
- return k(s.tokens_saved);
274
- },
275
- },
276
- ];
277
-
278
- export function statsValues() {
279
- const v = {};
280
- for (const src of SAVINGS_SOURCES) {
281
- const data = readJson(src.path());
282
- if (!data) continue;
283
- const out = src.extract(data);
284
- if (out) v[src.key] = out; // omit on null/zero -> render.js available() drops the row
246
+ // Windows FS is case-insensitive, POSIX is not only fold case on win32 to avoid
247
+ // false path matches (e.g. /home/User vs /home/user) on the platforms the npm package also runs on.
248
+ const normPath = (p) => {
249
+ const s = String(p).replace(/\\/g, "/");
250
+ return process.platform === "win32" ? s.toLowerCase() : s;
251
+ };
252
+
253
+ // "8.3k 63%" saved + a per-session compression rate derived from accumulated totals.
254
+ function fmtSaved(st) {
255
+ return st.original > 0 ? `${k(st.saved)} ${Math.round(100 * st.saved / st.original)}%` : k(st.saved);
256
+ }
257
+
258
+ const savingsStatePath = (sid) =>
259
+ process.env.DOOMBAR_SAVINGS_STATE || path.join(TMP, `savings_${sid}.json`);
260
+
261
+ // Per-session lean-ctx savings. lean-ctx's mcp-live.json is a single global file clobbered
262
+ // by every concurrent session, so it can't be per-session. events.jsonl is append-only and
263
+ // each ToolCall carries the file path it compressed — so we sum tokens_saved over NEW events
264
+ // (tracked by byte offset) whose path is under the current cwd, accumulated across refreshes
265
+ // in a per-session state file keyed by session_id. The accumulator follows cwd changes (it
266
+ // keeps adding wherever you currently work) and stays cheap — it reads only the bytes appended
267
+ // since the last refresh, never the whole log. Residual: two sessions concurrently in the SAME
268
+ // project share events and both count them — unsplittable from disk, accepted.
269
+ function leanCtxSavings(cwd, sid) {
270
+ if (!cwd) return null;
271
+ const cwdN = normPath(cwd).replace(/\/+$/, "") + "/"; // match on a path boundary, not a prefix
272
+ const sp = savingsStatePath(sid);
273
+ let st = { offset: null, saved: 0, original: 0 };
274
+ try { st = { ...st, ...JSON.parse(readFileSync(sp, "utf8")) }; } catch { /* fresh session */ }
275
+
276
+ let size = -1;
277
+ try { size = statSync(eventsPath()).size; } catch { /* no log */ }
278
+ if (size < 0) return st.saved > 0 ? fmtSaved(st) : null; // log gone -> keep prior total
279
+
280
+ if (st.offset === null) st.offset = size; // first sight of this session: count from now on
281
+ // Log shrank -> lean-ctx rotated it (old events go to archives/, the restarted log holds only
282
+ // NEW events). Keep the running total and re-read from 0: prior total + new events = correct.
283
+ // (An in-place truncate-and-rewrite retaining old content would double-count, but an append-only
284
+ // log doesn't do that.)
285
+ if (st.offset > size) st.offset = 0;
286
+
287
+ if (size > st.offset) {
288
+ let chunk = "";
289
+ try {
290
+ const fd = openSync(eventsPath(), "r");
291
+ const buf = Buffer.alloc(size - st.offset);
292
+ readSync(fd, buf, 0, buf.length, st.offset);
293
+ closeSync(fd);
294
+ chunk = buf.toString("utf8");
295
+ } catch { chunk = ""; }
296
+ const lastNl = chunk.lastIndexOf("\n"); // consume complete lines only; keep any partial tail
297
+ if (lastNl >= 0) {
298
+ for (const ln of chunk.slice(0, lastNl).split("\n")) {
299
+ if (!ln) continue;
300
+ let o; try { o = JSON.parse(ln); } catch { continue; }
301
+ const ev = o.kind;
302
+ if (!ev || ev.type !== "ToolCall" || !ev.path) continue;
303
+ if (!normPath(ev.path).startsWith(cwdN)) continue; // cwdN ends in "/" -> boundary-safe
304
+ st.saved += ev.tokens_saved || 0;
305
+ st.original += ev.tokens_original || 0;
306
+ }
307
+ st.offset += Buffer.byteLength(chunk.slice(0, lastNl + 1), "utf8");
308
+ }
285
309
  }
310
+ try { writeFileSync(sp, JSON.stringify(st)); } catch { /* ignore */ }
311
+ return st.saved > 0 ? fmtSaved(st) : null;
312
+ }
313
+
314
+ // Per-session llmlingua. smart-read keys its sessions map by CLAUDE_CODE_SESSION_ID — the same
315
+ // id the statusbar gets on stdin — so we read sessions[sid] (not a single global block).
316
+ // Prefer last_saved_pct; else show the ratio. Flat lifetime-only writers expose no session -> absent.
317
+ function linguaSavings(sid) {
318
+ const d = readJson(llmlinguaPath());
319
+ const s = d && d.sessions && d.sessions[sid];
320
+ if (!s || !(s.tokens_saved > 0)) return null;
321
+ if (s.last_saved_pct != null) return `${k(s.tokens_saved)} ${Math.round(s.last_saved_pct)}%`;
322
+ if (s.last_ratio != null) return `${k(s.tokens_saved)} ${Number(s.last_ratio).toFixed(1)}x`;
323
+ return k(s.tokens_saved);
324
+ }
325
+
326
+ export function statsValues(data, cwd) {
327
+ const v = {};
328
+ const rawSid = String((data && data.session_id) || "default");
329
+ const sid = rawSid.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 48); // filesystem-safe state-file key
330
+ const lean = leanCtxSavings(cwd, sid);
331
+ if (lean) v["save.leanctx"] = lean;
332
+ // smart-read keys sessions[] by the RAW CLAUDE_CODE_SESSION_ID -> look up with the unsanitized id.
333
+ const ling = linguaSavings(rawSid);
334
+ if (ling) v["save.lingua"] = ling;
286
335
  return v;
287
336
  }
288
337
 
@@ -317,7 +366,9 @@ export function activityValues(st, now) {
317
366
  const squad = st.squad || {};
318
367
  v["act.agents"] = String(Object.keys(squad).length);
319
368
  const agents = Object.values(squad).sort((a, b) => a.start - b.start);
320
- v["act.subagents"] = agents.map((a) => [clip(a.desc || a.type || "agent", 24), _dur(now - a.start)]);
369
+ // Clip generously (not 24): the box caps width and the label marquees, so a long
370
+ // agent description should stay long enough to be worth scrolling through.
371
+ v["act.subagents"] = agents.map((a) => [clip(a.desc || a.type || "agent", 60), _dur(now - a.start)]);
321
372
 
322
373
  const tasks = st.tasks && typeof st.tasks === "object" ? Object.values(st.tasks) : [];
323
374
  const live = tasks.filter((t) => t.status !== "deleted");
@@ -328,7 +379,7 @@ export function activityValues(st, now) {
328
379
  .sort((a, b) => (TASK_ORDER[a.status] - TASK_ORDER[b.status]) || (a.ts - b.ts));
329
380
  v["act.tasklist"] = ordered.map((t) => {
330
381
  const [mark, markRgb] = TASK_MARK[t.status] || ["🎯", null];
331
- return { mark, markRgb, text: clip(t.title, 24) };
382
+ return { mark, markRgb, text: clip(t.title, 60) }; // generous clip: box width caps it, title marquees
332
383
  });
333
384
 
334
385
  if ("errors" in st) v["act.errors"] = String(st.errors);
@@ -339,13 +390,13 @@ function main() {
339
390
  let data = {};
340
391
  try { data = JSON.parse(readFileSync(0, "utf8")); } catch { data = {}; }
341
392
 
342
- const preset = process.env.DOOMBAR_PRESET || path.join(REPO, "presets", "default.toml");
393
+ const preset = process.env.DOOMBAR_PRESET || path.join(REPO, "presets", "standard.toml");
343
394
  const cfg = parseToml(readFileSync(preset, "utf8"));
344
395
 
345
396
  const now = Date.now() / 1000;
346
397
  const st = readState(data);
347
398
  const cwd = data.cwd || (data.workspace || {}).current_dir;
348
- const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd), ...statsValues() };
399
+ const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd), ...statsValues(data, cwd) };
349
400
  const [advModel, advTs] = advisorInfo(data.transcript_path || "");
350
401
  if (advModel) values["advisor.model"] = advModel;
351
402
  const god_until = godFlash(data, advTs, now);
@@ -376,7 +427,17 @@ function main() {
376
427
  };
377
428
 
378
429
  const target = parseInt(process.env.COLUMNS || "100", 10);
379
- const res = buildBar(cfg, target, spriteFor);
430
+ const tick = Math.floor(now); // one marquee step per refresh (~1s); pure fn of time
431
+ // Pick the preset that fits the terminal: the chosen preset is the ceiling; if it
432
+ // (at its minimum scale) overflows COLUMNS, fall back down its [bar].fallback chain.
433
+ // Sibling presets resolve relative to the chosen preset's directory.
434
+ const presetDir = path.dirname(preset);
435
+ const loadByName = (name) => {
436
+ try { return parseToml(readFileSync(path.join(presetDir, `${name}.toml`), "utf8")); }
437
+ catch { return null; }
438
+ };
439
+ const selected = resolvePreset(cfg, target, loadByName, spriteFor);
440
+ const res = buildBar(selected, target, spriteFor, tick);
380
441
  process.stdout.write(res.lines.join("\n") + "\n");
381
442
  }
382
443
 
@@ -1,38 +0,0 @@
1
- # default — balanced (the live-mockup look)
2
- [bar]
3
- border_style = "vertical"
4
- border_color = "term-fg"
5
- box_background = "term-bg"
6
- headers = true
7
-
8
- [mugshot]
9
- background = "#000000"
10
-
11
- [[segment]]
12
- type = "box"
13
- title = "USAGE"
14
- metric = [
15
- { id = "context.hp", render = "bar", icon = "🧠", color = "threshold" },
16
- { id = "ratelimit.5h", render = "bar", icon = "🕔", color = "threshold" },
17
- { id = "ratelimit.7d", render = "bar", icon = "📅", color = "threshold" },
18
- ]
19
-
20
- [[segment]]
21
- type = "box"
22
- title = "SAVE"
23
- metric = [
24
- { id = "save.leanctx", render = "text", icon = "🪶" },
25
- { id = "save.lingua", render = "text", icon = "📜" },
26
- ]
27
-
28
- [[segment]]
29
- type = "mugshot"
30
-
31
- [[segment]]
32
- type = "box"
33
- title = "GIT"
34
- metric = [
35
- { id = "git.branch", render = "text", icon = "🌿" },
36
- { group = ["git.behind", "git.ahead"], render = "number", sep = " ", icon = "⇅" },
37
- { id = "cost.total", render = "number", icon = "💰" },
38
- ]