Claude Inline Usage Tracker

Minimal usage bar below Claude input

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Claude Inline Usage Tracker
// @namespace    usage-tracker-of-claude
// @author       Niko
// @version      2.7
// @description  Minimal usage bar below Claude input
// @match        https://claude.ai/*
// @grant        none
// @run-at       document-idle
// @license      GNU General Public License v3.0
// ==/UserScript==

(() => {
  'use strict';

  const ID = 'cut', SID = 'cut-style', API = '/api/organizations';
  const POLL = 60_000, HOVER_REFRESH = 30_000, MIN_GAP = 15_000;
  const WARN = 60, DANGER = 80;
  const A = 'cut-anchor', H = 'cut-hover';

  const ROWS = [
    ['five_hour',      'Current Session'],
    ['seven_day',      'Weekly Limit (All)'],
    ['seven_day_opus', 'Weekly Limit (Opus)'],
  ];

  const S = { org: null, inflight: null, last: null, lastAt: 0, anchor: null, ui: null, poll: 0, sched: 0, mo: null };

  const clamp = (v) => (v = +v || 0) < 0 ? 0 : v > 100 ? 100 : v;
  const fmt = (iso) => {
    if (!iso) return 'N/A';
    const m = Math.round((new Date(iso).getTime() - Date.now()) / 60000);
    if (m < 1) return 'Resetting soon';
    if (m < 60) return `In ${m} min`;
    const h = (m / 60) | 0;
    return h < 24 ? `In ${h} hr` : `In ${((h / 24) | 0)} days`;
  };

  const jget = (u) => fetch(u, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error(r.status); return r.json(); });

  async function orgId() {
    if (S.org) return S.org;
    const orgs = await jget(API);
    return (S.org = orgs?.[0]?.uuid ?? null);
  }

  function usage(force) {
    const now = Date.now();
    if (!force && now - S.lastAt < MIN_GAP) return Promise.resolve(S.last);
    if (S.inflight) return S.inflight;

    return (S.inflight = (async () => {
      try {
        const id = await orgId();
        if (!id) return S.last;
        const d = await jget(`${API}/${id}/usage`);
        if (d) { S.last = d; S.lastAt = Date.now(); }
        return S.last;
      } catch (e) {
        S.org = null;
        return S.last;
      } finally {
        S.inflight = null;
      }
    })());
  }

  function style() {
    if (document.getElementById(SID)) return;
    const s = document.createElement('style');
    s.id = SID;
    s.textContent = `
#${ID}{position:absolute;inset:auto 16px -15px;z-index:30;font-family:var(--font-ui,system-ui,-apple-system,Segoe UI,Roboto,sans-serif);color:hsl(var(--text-100))}
#${ID} .t{height:12px;display:flex;align-items:center;cursor:pointer}
#${ID} .b{width:100%;height:3px;background:hsla(var(--border-300)/.12);border-radius:999px;overflow:hidden;transition:height .16s ease}
#${ID} .t:hover .b{height:4px}
#${ID} .f{height:100%;width:0%;background:hsl(var(--brand-000));transition:width .25s ease}
#${ID} .w{background:hsl(var(--warning-100))}
#${ID} .d{background:hsl(var(--danger-100))}
#${ID} .p{position:absolute;bottom:14px;left:0;right:0;background:hsl(var(--bg-000));border-radius:16px;display:flex;flex-direction:column;gap:10px;padding:12px 14px 10px;box-shadow:0 .25rem 1.25rem hsl(var(--always-black)/3.5%),0 0 0 .5px hsla(var(--border-300)/.15);opacity:0;visibility:hidden;pointer-events:none;transform:translateY(8px);transition:opacity .16s ease,transform .16s ease,visibility 0s linear .16s}
#${ID} .t:hover + .p{opacity:1;visibility:visible;transform:translateY(0);transition:opacity .16s ease,transform .16s ease}
#${ID} .hh{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;margin-bottom:6px;font-size:13px;line-height:1.1}
#${ID} .l{font-weight:550;color:hsl(var(--text-100))}
#${ID} .m{font-size:12px;font-weight:430;color:hsl(var(--text-500));white-space:nowrap}
#${ID} .k{width:100%;height:6px;background:hsla(var(--border-300)/.12);border-radius:999px;overflow:hidden}
.${A}{transition:background-color .2s ease,box-shadow .2s ease,border-color .2s ease}
.${A}.${H}{background-color:transparent!important;box-shadow:none!important;border-color:transparent!important}
.${A}>:not(#${ID}){transition:opacity .2s ease}
.${A}.${H}>:not(#${ID}){opacity:0!important;pointer-events:none!important}
@media (prefers-reduced-motion:reduce){#${ID} .b,#${ID} .f,#${ID} .p,.${A},.${A}>:not(#${ID}){transition:none!important}}
`;
    document.head.appendChild(s);
  }

  function clsFor(p) { return p > DANGER ? 'f d' : p > WARN ? 'f w' : 'f'; }

  function setFill(el, p) {
    const sp = '' + p;
    if (el.dataset.p !== sp) {
      el.dataset.p = sp;
      el.style.width = sp + '%';
      const c = clsFor(p);
      if (el.className !== c) el.className = c;
    }
  }

  function build() {
    const root = document.createElement('div');
    root.id = ID;

    root.innerHTML =
      `<div class="t"><div class="b"><div class="f" data-role="tf"></div></div></div>` +
      `<div class="p">` +
      ROWS.map(([, label], i) =>
        `<div class="r" data-i="${i}">` +
          `<div class="hh"><span class="l">${label}</span><span class="m" data-role="m"></span></div>` +
          `<div class="k"><div class="f" data-role="f"></div></div>` +
        `</div>`
      ).join('') +
      `</div>`;

    const tf = root.querySelector('[data-role="tf"]');
    const rEls = [...root.querySelectorAll('.r')];
    const metas = rEls.map(r => r.querySelector('[data-role="m"]'));
    const fills = rEls.map(r => r.querySelector('[data-role="f"]'));

    root.addEventListener('pointerenter', () => {
      S.anchor && S.anchor.classList.add(H);
      if (Date.now() - S.lastAt > HOVER_REFRESH) refresh(1);
    }, { passive: true });
    root.addEventListener('pointerleave', () => { S.anchor && S.anchor.classList.remove(H); }, { passive: true });

    return { root, tf, rEls, metas, fills };
  }

  function render(d) {
    if (!S.ui || !d) return;

    setFill(S.ui.tf, clamp(d?.five_hour?.utilization));

    for (let i = 0; i < ROWS.length; i++) {
      const key = ROWS[i][0];
      const b = d?.[key];
      const row = S.ui.rEls[i];
      if (!b) { row.hidden = true; continue; }
      row.hidden = false;

      const p = clamp(b.utilization);
      setFill(S.ui.fills[i], p);

      const t = `${p}% · ${fmt(b.resets_at)}`;
      const m = S.ui.metas[i];
      if (m.dataset.t !== t) { m.dataset.t = t; m.textContent = t; }
    }
  }

  async function refresh(force) {
    if (!S.ui || (!force && document.hidden)) return;
    render(await usage(!!force));
  }

  function findAnchor() {
    const ed = document.querySelector('[contenteditable="true"].tiptap');
    if (!ed) return null;
    const fs = ed.closest('fieldset');
    if (!fs) return null;
    return fs.querySelector('div[class*="bg-bg-000"][class*="rounded-[20px]"]') || fs;
  }

  function attach() {
    const a = findAnchor();
    if (!a) return;

    const existing = document.getElementById(ID);
    if (a === S.anchor && existing && a.contains(existing)) return;

    existing && existing.remove();

    a.classList.add(A);
    if (getComputedStyle(a).position === 'static') a.style.position = 'relative';

    S.anchor = a;
    S.ui = build();
    a.insertBefore(S.ui.root, a.firstChild);

    refresh(1);
  }

  function scheduleAttach() {
    if (S.sched) return;
    const cb = () => { S.sched = 0; attach(); };
    S.sched = window.requestIdleCallback ? requestIdleCallback(cb, { timeout: 800 }) : requestAnimationFrame(cb);
  }

  function startPoll() {
    stopPoll();
    const tick = () => {
      if (document.hidden) { S.poll = 0; return; }
      refresh(0);
      S.poll = setTimeout(tick, POLL);
    };
    S.poll = setTimeout(tick, POLL);
  }

  function stopPoll() { S.poll && clearTimeout(S.poll); S.poll = 0; }

  function hooks() {
    const patch = (m) => {
      const o = history[m];
      history[m] = function () { const r = o.apply(this, arguments); scheduleAttach(); return r; };
    };
    patch('pushState'); patch('replaceState');
    addEventListener('popstate', scheduleAttach, { passive: true });
    addEventListener('hashchange', scheduleAttach, { passive: true });

    let t = 0;
    S.mo = new MutationObserver(() => {
      if (t) return;
      t = setTimeout(() => { t = 0; scheduleAttach(); }, 200);
    });
    S.mo.observe(document.body, { childList: true, subtree: true });

    document.addEventListener('visibilitychange', () => {
      if (document.hidden) stopPoll();
      else { scheduleAttach(); refresh(1); startPoll(); }
    }, { passive: true });

    addEventListener('focus', () => !document.hidden && refresh(1), { passive: true });
  }

  function init() {
    style();
    hooks();
    scheduleAttach();
    startPoll();
  }

  init();
})();