claude-doom-statusbar 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-doom-statusbar",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "DOOM-inspired status bar for the Claude Code CLI — a mugshot that tracks session health, plus usage, model, project, system, and a live subagent list.",
5
5
  "type": "module",
6
6
  "bin": {
package/presets/full.toml CHANGED
@@ -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
package/src/render.js CHANGED
@@ -66,6 +66,28 @@ export function vlen(s) {
66
66
  return n;
67
67
  }
68
68
 
69
+ // OSC8 hyperlink helpers — long hyperlinked labels (cwd, branch) can't be column-sliced
70
+ // as-is (slicing corrupts the escape), so we operate on the visible text and re-wrap it
71
+ // with the same URL. Matches the format emitted by statusline's _link().
72
+ const OSC8_RE = /^\x1b\]8;;([^\x1b\x07]*)(?:\x1b\\|\x07)([\s\S]*?)\x1b\]8;;(?:\x1b\\|\x07)$/;
73
+ function splitLink(s) {
74
+ const m = String(s).match(OSC8_RE);
75
+ return m ? { url: m[1], inner: m[2] } : null;
76
+ }
77
+ function wrapLink(text, url) {
78
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
79
+ }
80
+ // First `width` visible columns of `text`, no padding, never splitting a 2-col glyph.
81
+ function headCols(text, width) {
82
+ let col = 0, out = "";
83
+ for (const ch of [...String(text)]) {
84
+ const cw = vlen(ch);
85
+ if (col + cw > width) break;
86
+ out += ch; col += cw;
87
+ }
88
+ return out;
89
+ }
90
+
69
91
  function threshold(pct) {
70
92
  return pct < 60 ? OK : pct < 85 ? WARN : CRIT;
71
93
  }
@@ -213,7 +235,11 @@ const ESC_RE = /\x1b/;
213
235
  function capLen(s, textCap) {
214
236
  const str = String(s);
215
237
  const w = vlen(str);
216
- return ESC_RE.test(str) ? w : Math.min(w, textCap);
238
+ // plain text and OSC8 hyperlinks are column-sliceable (marquee/clip operate on the
239
+ // visible text), so cap them to textCap. Other escapes (raw SGR we can't safely slice)
240
+ // keep their full width as a hard floor.
241
+ const sliceable = !ESC_RE.test(str) || OSC8_RE.test(str);
242
+ return sliceable ? Math.min(w, textCap) : w;
217
243
  }
218
244
 
219
245
  export function metricFixedWidth(entry, textCap = TEXTCAP_MAX) {
@@ -304,11 +330,18 @@ function sliceCols(text, off, width) {
304
330
 
305
331
  // Fit `text` into exactly `width` display columns. Fits -> left-aligned + padded.
306
332
  // Overflows -> ping-pong marquee window for the current `tick`.
307
- export function marquee(text, width, tick = 0) {
333
+ export function marquee(text, width, tick = 0, mode = "scroll") {
308
334
  text = String(text);
309
335
  if (width <= 0) return "";
336
+ const link = splitLink(text); // hyperlink: fit the visible text, re-wrap
337
+ if (link) return wrapLink(marquee(link.inner, width, tick, mode), link.url);
310
338
  const tw = vlen(text);
311
339
  if (tw <= width) return text + " ".repeat(width - tw);
340
+ if (mode === "clip") { // static truncation with an ellipsis
341
+ if (width === 1) return "…";
342
+ const h = headCols(text, width - 1);
343
+ return h + "…" + " ".repeat(Math.max(0, width - vlen(h) - 1));
344
+ }
312
345
  return sliceCols(text, marqueeOffset(tw - width, tick), width);
313
346
  }
314
347
 
@@ -428,8 +461,10 @@ export function resolvePreset(chosenCfg, target, loadByName, spriteFor) {
428
461
  return last; // nothing fit -> smallest reached
429
462
  }
430
463
 
431
- export function buildBar(cfg, target, spriteFor, tick = 0) {
464
+ export function buildBar(cfg, target, spriteFor, tick = 0, overflow) {
432
465
  if (!spriteFor) spriteFor = (hp) => `STFST${hp}1`;
466
+ // How overflowing text fits its box: "scroll" (ping-pong marquee) or "clip" (static …).
467
+ const ovf = overflow || cfg.text_overflow || "scroll";
433
468
 
434
469
  const { style, headers, segs, totalRows, hp, face, faceW } = layoutContext(cfg, spriteFor);
435
470
  const bar = cfg.bar || {};
@@ -464,10 +499,10 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
464
499
  if (Array.isArray(item) && item.length === 2) {
465
500
  const right = f(TEXT) + String(item[1]);
466
501
  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);
502
+ const left = lbl + f(TEXT) + marquee(String(item[0]), budget, tick, ovf);
468
503
  body = left + " ".repeat(Math.max(0, w - vlen(left) - vlen(String(item[1])))) + right;
469
504
  } else {
470
- body = lbl + f(TEXT) + marquee(String(item), Math.max(0, w - vlen(lbl)), tick);
505
+ body = lbl + f(TEXT) + marquee(String(item), Math.max(0, w - vlen(lbl)), tick, ovf);
471
506
  }
472
507
  col.push(bgsgrBox(boxRgb) + " " + body + " " + RESET);
473
508
  }
@@ -492,7 +527,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
492
527
  const right = f(TEXT) + String(item[1]) + (marker ? f(TEXT) + tail : "");
493
528
  const rightW = vlen(String(item[1])) + tailW;
494
529
  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);
530
+ const left = lbl + f(TEXT) + marquee(String(item[0]), labelMax, tick, ovf);
496
531
  const room = Math.max(0, w - vlen(left) - rightW);
497
532
  body = left + " ".repeat(room) + right;
498
533
  } else { // {mark, markRgb, text} (tasks)
@@ -501,7 +536,7 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
501
536
  const mPad = m + (vlen(m) < 2 ? " " : ""); // normalize mark to 2 cols so text aligns
502
537
  const head = markCol + mPad + " " + f(TEXT);
503
538
  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);
539
+ body = head + marquee(String(item.text), max, tick, ovf);
505
540
  body += " ".repeat(Math.max(0, w - tailW - vlen(body)));
506
541
  if (tail) body += f(TEXT) + tail;
507
542
  }
@@ -520,11 +555,12 @@ export function buildBar(cfg, target, spriteFor, tick = 0) {
520
555
  const raw = String(VALUES[m.id]);
521
556
  const lbl = m.icon ? m.icon + " " : "";
522
557
  const budget = w - vlen(lbl) - vlen(rhs);
523
- if (!/[\x1b]/.test(raw) && budget > 0 && vlen(raw) > budget) {
558
+ const sliceable = !ESC_RE.test(raw) || OSC8_RE.test(raw); // plain or hyperlink
559
+ if (sliceable && budget > 0 && vlen(raw) > budget) {
524
560
  let col;
525
561
  if (m.color === "threshold") col = threshold(parseInt(raw.replace(/\D/g, "") || "0", 10));
526
562
  else col = m.color ? rgbOf(m.color) : TEXT;
527
- body = lbl + f(col) + marquee(raw, budget, tick);
563
+ body = lbl + f(col) + marquee(raw, budget, tick, ovf);
528
564
  }
529
565
  }
530
566
  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}`;
@@ -471,7 +473,9 @@ function main() {
471
473
  catch { return null; }
472
474
  };
473
475
  const selected = resolvePreset(cfg, target, loadByName, spriteFor);
474
- const res = buildBar(selected, target, spriteFor, tick);
476
+ // Text overflow behavior: env wins, then the preset's text_overflow, default "scroll".
477
+ const overflow = process.env.DOOMBAR_TEXT_OVERFLOW || selected.text_overflow || cfg.text_overflow || "scroll";
478
+ const res = buildBar(selected, target, spriteFor, tick, overflow);
475
479
  process.stdout.write(res.lines.join("\n") + "\n");
476
480
  }
477
481