claude-doom-statusbar 0.3.1 → 0.5.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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-doom-statusbar",
3
- "version": "0.3.1",
3
+ "version": "0.5.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/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",
19
19
  "preversion": "npm test",
20
20
  "postversion": "git push --follow-tags",
21
21
  "prepublishOnly": "npm test"
@@ -17,6 +17,14 @@ metric = [
17
17
  { id = "ratelimit.7d", render = "bar", icon = "📅", color = "threshold" },
18
18
  ]
19
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
+
20
28
  [[segment]]
21
29
  type = "mugshot"
22
30
 
package/presets/full.toml CHANGED
@@ -30,6 +30,14 @@ metric = [
30
30
  { id = "cost.total", render = "number", icon = "💰" },
31
31
  ]
32
32
 
33
+ [[segment]]
34
+ type = "box"
35
+ title = "SAVE"
36
+ metric = [
37
+ { id = "save.leanctx", render = "text", icon = "ðŸŠķ" },
38
+ { id = "save.lingua", render = "text", icon = "📜" },
39
+ ]
40
+
33
41
  [[segment]]
34
42
  type = "box"
35
43
  title = "PROJECT"
package/src/render.js CHANGED
@@ -47,6 +47,7 @@ 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
+ "save.leanctx": "8.3k 63%", "save.lingua": "1.2k 1.3x",
50
51
  };
51
52
 
52
53
  let VALUES = SAMPLE;
package/src/statusline.js CHANGED
@@ -10,7 +10,7 @@
10
10
  // Config: $DOOMBAR_PRESET (default presets/default.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";
@@ -45,6 +45,10 @@ function git(cwd, ...args) {
45
45
  // so an oversized repo or branch name can't blow up the PROJECT box width.
46
46
  const clip = (s, n) => ([...String(s)].length > n ? [...String(s)].slice(0, n - 1).join("") + "â€Ķ" : String(s));
47
47
 
48
+ // Human-readable token count: 8263 -> "8.3k", 1200000 -> "1.2M", 512 -> "512".
49
+ // Lowercase k/M — the savings rows read softer than model.window's uppercase K/M.
50
+ const k = (n) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : `${n}`);
51
+
48
52
  function _dur(secsF) {
49
53
  let secs = Math.max(0, Math.trunc(secsF));
50
54
  const d = Math.floor(secs / 86400); secs %= 86400;
@@ -228,6 +232,109 @@ function sysValues(cwd) {
228
232
  return v;
229
233
  }
230
234
 
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
+ const llmlinguaPath = () => process.env.DOOMBAR_LLMLINGUA || path.join(os.homedir(), ".llmlingua-stats.json");
240
+
241
+ // Defensive read: missing file or malformed JSON -> null (the row simply never appears).
242
+ function readJson(p) {
243
+ try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; }
244
+ }
245
+
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
+ }
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;
335
+ return v;
336
+ }
337
+
231
338
  const PERM = { plan: "📋 plan", auto: "âĐ auto", acceptEdits: "âĐ auto", bypassPermissions: "âĐ bypass" };
232
339
  const OK_RGB = [96, 200, 104]; // matches render.js OK (done, green)
233
340
  const CRIT_RGB = [224, 84, 64]; // matches render.js CRIT (deleted, red)
@@ -287,7 +394,7 @@ function main() {
287
394
  const now = Date.now() / 1000;
288
395
  const st = readState(data);
289
396
  const cwd = data.cwd || (data.workspace || {}).current_dir;
290
- const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd) };
397
+ const values = { ...buildValues(data), ...activityValues(st, now), ...sysValues(cwd), ...statsValues(data, cwd) };
291
398
  const [advModel, advTs] = advisorInfo(data.transcript_path || "");
292
399
  if (advModel) values["advisor.model"] = advModel;
293
400
  const god_until = godFlash(data, advTs, now);