Luigit
repositories / dotfiles

dotfiles

bugabingas dorkfiles

owned by admin

claude/statusline.js

Raw
#!/usr/bin/env node
// Claude Code statusline (Node). Reads session JSON from stdin, prints one line.
//
//   <dir> · <model> · <effort> · <git|jj> · <5h bar> · <7d bar> · <cost> · <cm> · <extra>
//
// Cost shows turn (Δ since last user prompt), session, and month-to-date.
// Month-to-date is a cross-session ledger: each render adds the session's new
// spend (delta) into the current month's bucket (~/.claude/.cc-cost-ledger.json).
//
// Must NEVER throw — a crash blanks the bar. Every optional part is its own
// try/catch. Schema: https://code.claude.com/docs/en/statusline.md

"use strict";

const fs = require("fs");
const os = require("os");
const path = require("path");
const crypto = require("crypto");
const { execFileSync } = require("child_process");

// ─── CONFIG ──────────────────────────────────────────────────────────────────
const CONFIG = {
  sep: " · ", // segment padding (single-space around the dot — tight)
  barWidth: 8, // usage bar cells
  contextWarnPct: 70,
  contextCritPct: 90,
  usageAlways: true,
  usageWarnPct: 50,
  usageCritPct: 80,

  costStyle: "icon", // "slash" → $0.15/turn   |   "icon" → bright $0.15 + dim trailing glyph
  gitCacheTtlMs: 8000, // git status spawn is ~400ms cold on win32 — cache harder
  extraTtlMs: 120000,
  cmdTimeoutMs: 2500,
  ledgerPruneDays: 40,
  // burn rate ($/hr): recent-window spend rate, decay-proof (idle samples age out)
  burnWindowMs: 10 * 60000, // sliding window of cost samples to rate over
  burnMinSpanMs: 60000, // need ≥ this much elapsed inside window before showing
  burnMaxSamples: 60, // cap stored samples per session (keeps ledger small)
  // burn-rate color: truecolor gradient stops ($/hr). grounded in this ledger
  // (median ~$9.5, p90 ~$19, peak intervals ~$50) + Opus 4.8 pricing ($5/$25/MTok):
  // ~$8 calm single-stream · ~$18 heavy/active · ~$36+ parallel-agent burn.
  // smooth lerp between stops — these are gradient anchors, not hard tiers.
  burnCool: 8, // ≤ this → pure green (calm)
  burnWarn: 18, // amber midpoint (≈ p90 — notably elevated)
  burnHot: 36, // ≥ this → pure red (HOT — deep tail)
  burnBold: 28, // ≥ this → bold weight (visual alarm)
};

const claudeDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");

// ─── ANSI ────────────────────────────────────────────────────────────────────
const E = "\x1b";
const R = `${E}[0m`;
const c = {
  cyan: (s) => `${E}[36m${s}${R}`,
  magenta: (s) => `${E}[35m${s}${R}`,
  green: (s) => `${E}[32m${s}${R}`,
  yellow: (s) => `${E}[33m${s}${R}`,
  red: (s) => `${E}[31m${s}${R}`,
  blue: (s) => `${E}[34m${s}${R}`,
  dim: (s) => `${E}[2m${s}${R}`,
  white: (s) => `${E}[97m${s}${R}`,
  accent: (s) => `${E}[93m${s}${R}`, // bright yellow — warm badge accent (no orange in the 16-index palette)
};

const lerp = (a, b, t) => Math.round(a + (b - a) * t); // single channel
const mix = (from, to, t) => from.map((v, i) => lerp(v, to[i], t)); // 3-channel lerp

// Shared color basis. Base colors are ANSI 16-index (38;5;N, N<16) so the terminal THEME
// remaps them on dark↔light. A gradient between two indices can't itself be an index, so
// it's lerp'd in truecolor from each index's reference RGB — only the transient middle of
// a ramp is non-adaptive; every anchor follows the theme.
const IDX = { calm: 12, warn: 13, crit: 9 }; // bars: blue → magenta → red (all theme-adaptive)
const BURN = { cool: 2, warm: 3, hot: 1 }; // 🔥 burn: green → yellow → red (kept distinct from bars)
const IDX_RGB = { 1: [205, 0, 0], 2: [0, 205, 0], 3: [205, 205, 0], 9: [255, 0, 0], 13: [255, 0, 255] }; // xterm refs — lerp blends only
const sgr = (code, s) => `${E}[${code}m${s}${R}`;
const idxColor = (n, bold) => (s) => sgr(`${bold ? "1;" : ""}38;5;${n}`, s); // adaptive anchor
const rgbColor = (rgb, bold) => (s) => sgr(`${bold ? "1;" : ""}38;2;${rgb[0]};${rgb[1]};${rgb[2]}`, s); // blend

// 🔥 burn-rate color: green (calm) → yellow → red (HOT, bold past burnBold). anchors are
// indices; the warming middle is a truecolor blend. render gates on burnRate>0 (no NaN).
function burnColor(rate, label) {
  const { burnCool, burnWarn, burnHot, burnBold } = CONFIG;
  const bold = rate >= burnBold;
  if (rate <= burnCool) return idxColor(BURN.cool, bold)(label);
  if (rate >= burnHot) return idxColor(BURN.hot, bold)(label);
  const rgb =
    rate <= burnWarn
      ? mix(IDX_RGB[BURN.cool], IDX_RGB[BURN.warm], (rate - burnCool) / (burnWarn - burnCool))
      : mix(IDX_RGB[BURN.warm], IDX_RGB[BURN.hot], (rate - burnWarn) / (burnHot - burnWarn));
  return rgbColor(rgb, bold)(label);
}

const trough = idxColor(IDX.calm, false); // empty cells — same adaptive index as calm, ░ glyph separates
// severity → colorizer. all three anchors are ANSI indices (theme-adaptive); only the
// strictly-between warn→crit blend is truecolor (lerp of the two anchors' reference RGB).
function meterColor(pct, warn, crit) {
  if (pct < warn) return idxColor(IDX.calm, false); // calm — blue
  if (pct >= crit) return idxColor(IDX.crit, true); // crit — red + bold
  const t = (pct - warn) / (crit - warn);
  if (t === 0) return idxColor(IDX.warn, false); // exactly at warn — yellow
  return rgbColor(mix(IDX_RGB[IDX.warn], IDX_RGB[IDX.crit], t), false); // smooth blend
}
// fill bar with shade-density leading edge (░▒▓); color from meterColor (index-based).
function bar(pct, width, warn, crit) {
  const p = Math.max(0, Math.min(100, pct));
  const filled = (p / 100) * width;
  const full = Math.min(width, Math.floor(filled));
  const frac = filled - full;
  const tip = frac > 0 && full < width ? (frac < 1 / 3 ? "░" : frac < 2 / 3 ? "▒" : "▓") : "";
  const empty = Math.max(0, width - full - (tip ? 1 : 0));
  return meterColor(p, warn, crit)("█".repeat(full) + tip) + trough("░".repeat(empty));
}
const money = (n) => (n < 1 ? n.toFixed(3) : n.toFixed(2));

// label + 8-cell bar + pct% — shared by ctx and usage. severity drives color (subtle→loud).
const meter = (label, pct, warn, crit) =>
  `${c.dim(label)} ${bar(pct, CONFIG.barWidth, warn, crit)} ${meterColor(pct, warn, crit)(pct + "%")}`;
// drop control chars (ANSI-injection / newline guard) before printing untrusted file text.
const stripCtl = (s) => s.replace(/[\x00-\x1F]/g, "");

// ─── crash-safe IO helpers (all swallow — a failed read/write must never blank the bar) ──
// atomic write: tmp + rename so a concurrent reader never sees a half-written file.
function atomicWrite(file, data) {
  try {
    const tmp = `${file}.${process.pid}.tmp`;
    fs.writeFileSync(tmp, data);
    fs.renameSync(tmp, file);
  } catch {}
}
// read a small plain file as text, or null. rejects symlinks (don't follow a planted
// link out of the config dir) and oversized files; lstat keeps the symlink unfollowed.
function readSmallFile(file, maxSize) {
  try {
    const st = fs.lstatSync(file);
    if (st.isSymbolicLink() || st.size > maxSize) return null;
    return fs.readFileSync(file, "utf8");
  } catch {
    return null;
  }
}
// parse a JSON file, or null on missing/corrupt.
function readJson(file) {
  try {
    return JSON.parse(fs.readFileSync(file, "utf8"));
  } catch {
    return null;
  }
}

// ─── stdin ───────────────────────────────────────────────────────────────────
let d = {};
try {
  const raw = fs.readFileSync(0, "utf8");
  if (raw && raw.trim()) d = JSON.parse(raw);
} catch {
  d = {};
}

const parts = [];
const push = (s) => {
  if (s) parts.push(s);
};
const cwd = d?.workspace?.current_dir || d?.cwd;

// ─── 1. dir ──────────────────────────────────────────────────────────────────
try {
  if (cwd) push(c.cyan(path.basename(cwd) || cwd));
} catch {}

// ─── 2. model ──────────────────────────────────────────────────────────────────
try {
  // strip the " (1M context)" / any trailing parenthetical — keep just "Opus 4.8"
  if (d?.model?.display_name) push(c.magenta(d.model.display_name.replace(/\s*\([^)]*\)\s*$/, "")));
} catch {}

// ─── 3. effort / thinking ──────────────────────────────────────────────────────
try {
  const lvl = d?.effort?.level;
  if (lvl) {
    const on = d?.thinking?.enabled !== false;
    const col = lvl === "max" || lvl === "xhigh" ? c.magenta : lvl === "high" ? c.blue : c.dim;
    push(on ? col(lvl) : c.dim(lvl)); // no ⚡ glyph — level word alone
  }
} catch {}

// ─── 4. git / jj (smart-cached) ────────────────────────────────────────────────
push(gitOrJjSegment(cwd));

// ─── 5. context (gated) ────────────────────────────────────────────────────────
try {
  // used_percentage is model-aware (relative to context_window_size: 1M for extended models).
  // Do NOT use exceeds_200k_tokens — it's a fixed 200k threshold, so it falsely reads 100%
  // on a 1M-window model once a long session passes 200k tokens.
  const cw = d?.context_window || {};
  let pct = cw.used_percentage;
  if (pct == null && cw.context_window_size && cw.total_input_tokens != null) {
    pct = (cw.total_input_tokens / cw.context_window_size) * 100;
  }
  if (pct != null) push(meter("ctx", Math.round(pct), CONFIG.contextWarnPct, CONFIG.contextCritPct));
} catch {}

// ─── 6. usage bars (rate limits) ───────────────────────────────────────────────
try {
  const rl = d?.rate_limits;
  if (rl) {
    const add = (label, win, withReset) => {
      if (win && typeof win.used_percentage === "number") {
        const p = Math.round(win.used_percentage);
        if (CONFIG.usageAlways || p >= CONFIG.usageWarnPct) {
          let seg = meter(label, p, CONFIG.usageWarnPct, CONFIG.usageCritPct);
          // resets_at is UNIX EPOCH SECONDS — ×1000 to compare with Date.now() (ms)
          if (withReset && typeof win.resets_at === "number") {
            const left = win.resets_at * 1000 - Date.now();
            if (left > 0) seg += c.dim("·" + fmtDuration(left));
          }
          push(seg);
        }
      }
    };
    add("5h", rl.five_hour, true);
    add("7d", rl.seven_day, false);
  }
} catch {}

// ─── 7. cost: turn · session · month ───────────────────────────────────────────
try {
  const total = d?.cost?.total_cost_usd;
  const sid = d?.session_id || "default";
  if (typeof total === "number") {
    const { turn, month, burnRate } = updateLedger(sid, total);
    // 🔥 burn rate: own segment, before the cost cluster (matches bars · 🔥 · cost)
    if (burnRate > 0) push(burnColor(burnRate, ` $${burnRate.toFixed(2)}/hr`));
    // icon style: bright $number (emphasis) + dim trailing glyph (muted icon).
    // turn has no glyph (bare $num); session Σ / month ☾. slash: $num/type.
    const fmt = (n, type, icon) =>
      CONFIG.costStyle === "icon"
        ? c.white(`$${money(n)}`) + (icon ? c.dim(icon) : "")
        : c.white(`$${money(n)}/${type}`);
    const seg = [];
    if (turn > 0) seg.push(fmt(turn, "turn", ""));
    seg.push(fmt(total, "session", "Σ"));
    if (month > 0) seg.push(fmt(month, "month", "☾"));
    push(seg.join(" "));
  }
} catch {}

// ─── 8. caveman badge (terse) ──────────────────────────────────────────────────
push(cavemanBadge());

// ─── 9. extra badges (hooks: workflow, …) ──────────────────────────────────────
try {
  const content = readSmallFile(path.join(claudeDir, ".cc-statusline-extra"), 4096);
  if (content) {
    const now = Date.now();
    for (const line of content.split(/\r?\n/)) {
      const i = line.indexOf("|");
      if (i <= 0) continue;
      const ts = Number(line.slice(0, i));
      if (!Number.isFinite(ts) || now - ts > CONFIG.extraTtlMs) continue;
      const text = stripCtl(line.slice(i + 1)).slice(0, 60);
      if (text) push(c.accent(text));
    }
  }
} catch {}

// ─── emit ──────────────────────────────────────────────────────────────────────
process.stdout.write(parts.join(CONFIG.sep));

// ════════════════════════════════════════════════════════════════════════════
// cost ledger — cross-session month-to-date + per-session turn baseline
// ════════════════════════════════════════════════════════════════════════════
function monthKey() {
  const dt = new Date();
  return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}`;
}

function updateLedger(sid, total) {
  const file = path.join(claudeDir, ".cc-cost-ledger.json");
  const now = Date.now();
  const mk = monthKey();
  let L = { month: mk, monthTotal: 0, sessions: {} };
  const parsed = readJson(file);
  if (parsed) L = parsed;
  if (!L.sessions) L.sessions = {};
  let dirty = false; // persist (disk write) only when something material changed

  // New month → reset the bucket, KEEP per-session baselines so a session that
  // straddles midnight only contributes its post-boundary spend.
  if (L.month !== mk) {
    L.month = mk;
    L.monthTotal = 0;
    dirty = true;
  }

  // Prune stale sessions.
  const cutoff = now - CONFIG.ledgerPruneDays * 864e5;
  for (const k of Object.keys(L.sessions)) {
    if ((L.sessions[k]?.ts ?? 0) < cutoff) delete L.sessions[k];
  }

  const s = L.sessions[sid] || { total: 0, turnStart: total, ts: now };

  // Turn boundary: UserPromptSubmit hook drops a marker; first render after a
  // prompt rebaselines turnStart, then consumes the marker.
  try {
    const marker = path.join(claudeDir, `.cc-turn-${sid}`);
    if (fs.existsSync(marker)) {
      s.turnStart = total;
      fs.unlinkSync(marker);
      dirty = true;
    }
  } catch {}

  // Month bucket gets only the new spend since this session was last seen.
  const delta = total - (s.total ?? 0);
  if (delta > 0) {
    L.monthTotal = (L.monthTotal || 0) + delta;
    dirty = true;
  }

  const turn = Math.max(0, total - (s.turnStart ?? total));

  // Burn-rate samples: only record a new (ts,total) when spend actually moved.
  // Idle re-renders carry no new info and would force a needless disk write.
  if (!Array.isArray(s.samples)) s.samples = [];
  if (delta > 0) {
    s.total = total;
    s.ts = now;
    s.samples.push([now, total]);
    if (s.samples.length > CONFIG.burnMaxSamples) s.samples = s.samples.slice(-CONFIG.burnMaxSamples);
  }
  // Prune the window in-memory every render (cheap, no disk) so even an idle
  // render shows a current-window rate rather than a stale one.
  const wcut = now - CONFIG.burnWindowMs;
  s.samples = s.samples.filter((p) => p[0] >= wcut);
  const burnRate = computeBurnRate(s.samples);

  L.sessions[sid] = s;

  // Persist only when something changed — skips stringify+write+rename (the bulk
  // of this function's cost) on the common idle re-render path.
  if (dirty) {
    atomicWrite(file, JSON.stringify(L));
    // Opportunistic GC (write-path only, so throttled to active turns): cancelled
    // renders leave `.cc-*.tmp` atomic-write orphans; ended sessions leave
    // `.cc-turn-*` markers. Sweep our own debris older than a day.
    try {
      const stale = now - 864e5;
      for (const f of fs.readdirSync(claudeDir)) {
        if (!((f.startsWith(".cc-") && f.endsWith(".tmp")) || f.startsWith(".cc-turn-"))) continue;
        const fp = path.join(claudeDir, f);
        try {
          if (fs.statSync(fp).mtimeMs < stale) fs.unlinkSync(fp);
        } catch {}
      }
    } catch {}
  }

  return { turn, month: L.monthTotal || 0, burnRate };
}

// ── burn rate ($/hr) ──────────────────────────────────────────────────────────
// DESIGN KNOB (yours to tune): turn timestamped cost samples into a "right now"
// $/hr. Default = window endpoints — cheap, robust: spend / span across the kept
// window. Stale samples are already pruned in updateLedger, so idle time drops
// out naturally rather than decaying the rate toward zero (the classic
// cost÷duration bug). Alternatives if you want a different feel:
//   • EWMA over per-sample rates → smoother, slower to react
//   • least-squares slope of (ts,total) → noise-resistant, a few more lines
function computeBurnRate(samples) {
  if (!Array.isArray(samples) || samples.length < 2) return 0;
  const first = samples[0];
  const last = samples[samples.length - 1];
  const span = last[0] - first[0];
  const spend = last[1] - first[1];
  if (span < CONFIG.burnMinSpanMs || spend <= 0) return 0;
  return (spend / span) * 3600000; // $/ms → $/hr
}

// Compact duration: "2h45m" (minutes zero-padded) above an hour, else "45m".
function fmtDuration(ms) {
  const m = Math.floor(ms / 60000);
  if (m >= 60) return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
  return `${m}m`;
}

// ════════════════════════════════════════════════════════════════════════════
// git / jj
// ════════════════════════════════════════════════════════════════════════════
function findRepo(startDir) {
  try {
    let dir = startDir && fs.existsSync(startDir) ? startDir : process.cwd();
    for (let i = 0; i < 40; i++) {
      if (fs.existsSync(path.join(dir, ".jj"))) return { kind: "jj", root: dir };
      if (fs.existsSync(path.join(dir, ".git"))) return { kind: "git", root: dir };
      const up = path.dirname(dir);
      if (up === dir) break;
      dir = up;
    }
  } catch {}
  return null;
}

function gitOrJjSegment(cwd) {
  let repo;
  try {
    repo = findRepo(cwd);
  } catch {
    return "";
  }
  if (!repo) return "";
  const key = crypto.createHash("sha1").update(repo.kind + ":" + repo.root).digest("hex").slice(0, 12);
  const cacheFile = path.join(os.tmpdir(), `cc-statusline-${key}.json`);
  const cached = readJson(cacheFile);
  if (cached && Date.now() - cached.ts < CONFIG.gitCacheTtlMs) return cached.text;
  let text = "";
  try {
    text = repo.kind === "jj" ? jjSegment(repo.root) : gitSegment(repo.root);
  } catch {
    text = "";
  }
  atomicWrite(cacheFile, JSON.stringify({ ts: Date.now(), text }));
  return text;
}

function gitSegment(root) {
  const out = execFileSync("git", ["-C", root, "status", "--porcelain=v2", "--branch"], {
    encoding: "utf8",
    timeout: CONFIG.cmdTimeoutMs,
    windowsHide: true,
  });
  let branch = "?";
  let ahead = 0;
  let behind = 0;
  let dirty = 0;
  for (const line of out.split(/\r?\n/)) {
    if (line.startsWith("# branch.head ")) branch = line.slice(14).trim();
    else if (line.startsWith("# branch.ab ")) {
      const m = line.match(/\+(\d+)\s+-(\d+)/);
      if (m) {
        ahead = +m[1];
        behind = +m[2];
      }
    } else if (line && !line.startsWith("#")) dirty++;
  }
  if (branch === "(detached)") branch = "detached";
  const col = dirty > 0 ? c.yellow : c.green;
  let s = col(`${branch}`); // U+E0A0 powerline branch glyph (Nerd Font)
  if (dirty > 0) s += c.yellow(`●${dirty}`);
  if (ahead) s += c.cyan(`↑${ahead}`);
  if (behind) s += c.cyan(`↓${behind}`);
  return s;
}

function jjSegment(root) {
  const tmpl = 'change_id.shortest(8) ++ "|" ++ bookmarks ++ "|" ++ if(empty, "clean", "dirty")';
  const out = execFileSync(
    "jj",
    ["--no-pager", "log", "--no-graph", "--color", "never", "-r", "@", "-T", tmpl],
    { cwd: root, encoding: "utf8", timeout: CONFIG.cmdTimeoutMs, windowsHide: true }
  ).trim();
  const [change = "?", bookmarks = "", state = ""] = out.split("|");
  const label = bookmarks.trim() || change;
  const dirty = state.trim() === "dirty";
  const col = dirty ? c.yellow : c.green;
  return col(`jj:${label}`) + (dirty ? c.yellow("✗") : "");
}

// ════════════════════════════════════════════════════════════════════════════
// caveman badge — terse: full→"cm", else "cm:<first-letter>"
// ════════════════════════════════════════════════════════════════════════════
function cavemanBadge() {
  try {
    const raw = readSmallFile(path.join(claudeDir, ".caveman-active"), 64);
    if (raw == null) return "";
    let mode = (raw.split(/\r?\n/)[0] ?? "")
      .trim()
      .toLowerCase()
      .replace(/[^a-z0-9-]/g, "");
    const valid = new Set([
      "off", "lite", "full", "ultra",
      "wenyan-lite", "wenyan", "wenyan-full", "wenyan-ultra",
      "commit", "review", "compress",
    ]);
    if (!valid.has(mode) || mode === "off") return "";
    let out = c.dim("caveman:") + c.accent(mode || "full"); // muted label, bright value
    if (process.env.CAVEMAN_STATUSLINE_SAVINGS !== "0") {
      const sraw = readSmallFile(path.join(claudeDir, ".caveman-statusline-suffix"), 64);
      const sv = sraw && stripCtl(sraw).trimEnd();
      if (sv) out += " " + c.accent(sv);
    }
    return out;
  } catch {
    return "";
  }
}