claude-doom-statusbar 0.5.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 +7 -3
- package/bin/cli.js +2 -2
- package/package.json +2 -2
- package/presets/full.toml +3 -0
- package/presets/minimal.toml +44 -7
- package/presets/standard.toml +73 -0
- package/src/render.js +173 -53
- package/src/statusline.js +18 -6
- package/presets/default.toml +0 -38
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 |
|
|
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/
|
|
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
|
-
- **`
|
|
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 |
|
|
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 |
|
|
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.
|
|
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
|
]
|
package/presets/minimal.toml
CHANGED
|
@@ -1,19 +1,56 @@
|
|
|
1
|
-
# minimal —
|
|
1
|
+
# minimal — MODEL, USAGE, mugshot, PROJECT, ACTIVITY
|
|
2
2
|
[bar]
|
|
3
|
-
border_style = "
|
|
4
|
-
|
|
5
|
-
|
|
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 = "
|
|
9
|
+
background = "#000000"
|
|
9
10
|
|
|
10
11
|
[[segment]]
|
|
11
|
-
type
|
|
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 = "
|
|
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
|
-
|
|
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 +
|
|
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 +
|
|
224
|
-
: lw +
|
|
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 +
|
|
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 +
|
|
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 +
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
399
|
-
|
|
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", "
|
|
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,7 +7,7 @@
|
|
|
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/
|
|
10
|
+
// Config: $DOOMBAR_PRESET (default presets/standard.toml) State: $MUGSHOT_STATE
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
13
|
readFileSync, writeFileSync, openSync, fstatSync, readSync, closeSync, statfsSync, statSync,
|
|
@@ -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);
|
|
@@ -366,7 +366,9 @@ export function activityValues(st, now) {
|
|
|
366
366
|
const squad = st.squad || {};
|
|
367
367
|
v["act.agents"] = String(Object.keys(squad).length);
|
|
368
368
|
const agents = Object.values(squad).sort((a, b) => a.start - b.start);
|
|
369
|
-
|
|
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)]);
|
|
370
372
|
|
|
371
373
|
const tasks = st.tasks && typeof st.tasks === "object" ? Object.values(st.tasks) : [];
|
|
372
374
|
const live = tasks.filter((t) => t.status !== "deleted");
|
|
@@ -377,7 +379,7 @@ export function activityValues(st, now) {
|
|
|
377
379
|
.sort((a, b) => (TASK_ORDER[a.status] - TASK_ORDER[b.status]) || (a.ts - b.ts));
|
|
378
380
|
v["act.tasklist"] = ordered.map((t) => {
|
|
379
381
|
const [mark, markRgb] = TASK_MARK[t.status] || ["🎯", null];
|
|
380
|
-
return { mark, markRgb, text: clip(t.title,
|
|
382
|
+
return { mark, markRgb, text: clip(t.title, 60) }; // generous clip: box width caps it, title marquees
|
|
381
383
|
});
|
|
382
384
|
|
|
383
385
|
if ("errors" in st) v["act.errors"] = String(st.errors);
|
|
@@ -388,7 +390,7 @@ function main() {
|
|
|
388
390
|
let data = {};
|
|
389
391
|
try { data = JSON.parse(readFileSync(0, "utf8")); } catch { data = {}; }
|
|
390
392
|
|
|
391
|
-
const preset = process.env.DOOMBAR_PRESET || path.join(REPO, "presets", "
|
|
393
|
+
const preset = process.env.DOOMBAR_PRESET || path.join(REPO, "presets", "standard.toml");
|
|
392
394
|
const cfg = parseToml(readFileSync(preset, "utf8"));
|
|
393
395
|
|
|
394
396
|
const now = Date.now() / 1000;
|
|
@@ -425,7 +427,17 @@ function main() {
|
|
|
425
427
|
};
|
|
426
428
|
|
|
427
429
|
const target = parseInt(process.env.COLUMNS || "100", 10);
|
|
428
|
-
const
|
|
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);
|
|
429
441
|
process.stdout.write(res.lines.join("\n") + "\n");
|
|
430
442
|
}
|
|
431
443
|
|
package/presets/default.toml
DELETED
|
@@ -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
|
-
]
|