Claude Inline Usage Tracker

Minimal usage bar below Claude input

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==UserScript==
// @name         Claude Inline Usage Tracker
// @namespace    usage-tracker-of-claude
// @author       Niko
// @version      3.0.4
// @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';

  if (window.__cutLoaded) return;
  window.__cutLoaded = true;

  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 PRIMARY_ROWS = [
    ['five_hour',             'Current Session'],
    ['seven_day',             'Weekly Limit (All)'],
    ['seven_day_opus',        'Weekly Limit (Opus)'],
    ['seven_day_sonnet',      'Weekly Limit (Sonnet)'],
    ['seven_day_cowork',      'Weekly Limit (Cowork)'],
    ['seven_day_oauth_apps',  'Weekly Limit (OAuth Apps)'],
  ];

  const LIMIT_LABELS = {
    session: 'Current Session',
    weekly_all: 'Weekly Limit (All)',
    weekly_opus: 'Weekly Limit (Opus)',
    weekly_sonnet: 'Weekly Limit (Sonnet)',
    weekly_cowork: 'Weekly Limit (Cowork)',
    weekly_oauth_apps: 'Weekly Limit (OAuth Apps)',
    daily: 'Daily Limit',
    weekly: 'Weekly Limit',
    monthly: 'Monthly Limit',
  };

  const RESERVED = new Set([
    ...PRIMARY_ROWS.map(([key]) => key),
    'extra_usage', 'spend', 'limits', 'member_dashboard_available',
  ]);

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

  const clamp = (v) => {
    v = Math.round(Number(v) || 0);
    return v < 0 ? 0 : v > 100 ? 100 : v;
  };

  const num = (v) => {
    const n = Number(v);
    return Number.isFinite(n) ? n : null;
  };

  const percentOf = (value) => num(value?.utilization ?? value?.percent);

  const fmtReset = (iso) => {
    if (!iso) return '';
    const m = Math.round((new Date(iso).getTime() - Date.now()) / 60_000);
    if (!Number.isFinite(m)) return '';
    if (m < 1) return 'Resetting soon';
    if (m < 60) return `In ${m} min`;
    const h = Math.floor(m / 60);
    if (h < 24) return `In ${h} hr`;
    return `In ${Math.floor(h / 24)} days`;
  };

  const titleCase = (key) => key
    .replace(/^seven_day_/, 'weekly_')
    .replace(/[_-]+/g, ' ')
    .replace(/\b\w/g, c => c.toUpperCase())
    .replace(/Oauth/g, 'OAuth');

  const money = (minor, currency, exponent = 2) => {
    const amountMinor = num(minor);
    const exp = num(exponent);
    if (amountMinor === null || exp === null || !currency) return '';

    try {
      return new Intl.NumberFormat(undefined, {
        style: 'currency',
        currency,
        minimumFractionDigits: exp,
        maximumFractionDigits: exp,
      }).format(amountMinor / (10 ** exp));
    } catch {
      return `${currency} ${(amountMinor / (10 ** exp)).toFixed(exp)}`;
    }
  };

  const jget = (url) => fetch(url, { credentials: 'include' }).then(r => {
    if (!r.ok) throw new Error(String(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 data = await jget(`${API}/${id}/usage`);
        if (data) {
          S.last = data;
          S.lastAt = Date.now();
        }
        return S.last;
      } catch {
        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:100% 16px auto;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;max-height:min(70vh,520px);overflow:auto;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;pointer-events:none;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;text-align:right}
#${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}

fieldset:has(#${ID}) > .relative + div:has([role="status"]){margin-top:20px}
fieldset:has(#${ID}) > .relative + div [role="status"] .rounded-b-xl{border-radius:16px;padding-top:8px;padding-bottom:8px}
fieldset:has(#${ID}:hover) > [data-alert-band-wrapper="true"],
fieldset:has(#${ID} .t:hover) > [role="status"]{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
div:has(>div>fieldset #${ID}:hover)+div[aria-hidden="false"]{opacity:0;visibility:hidden;pointer-events:none;transition:opacity .16s ease,visibility 0s linear .16s}
[data-chat-input-container="true"]:has(#${ID}) > div:has(fieldset){transform:translateY(-6px)}

@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 value = clamp(p);
    const sp = String(value);
    if (el.dataset.p !== sp) {
      el.dataset.p = sp;
      el.style.width = `${sp}%`;
      const c = clsFor(value);
      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" data-role="panel"></div>`;

    const tf = root.querySelector('[data-role="tf"]');
    const panel = root.querySelector('[data-role="panel"]');

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

    root.addEventListener('pointerleave', () => {
      S.anchor?.classList.remove(H);
    }, { passive: true });

    return { root, tf, panel, rows: new Map() };
  }

  function createRow(label) {
    const row = document.createElement('div');
    row.className = 'r';

    const head = document.createElement('div');
    head.className = 'hh';

    const labelEl = document.createElement('span');
    labelEl.className = 'l';
    labelEl.textContent = label;

    const meta = document.createElement('span');
    meta.className = 'm';

    const track = document.createElement('div');
    track.className = 'k';

    const fill = document.createElement('div');
    fill.className = 'f';

    head.append(labelEl, meta);
    track.append(fill);
    row.append(head, track);

    return { row, labelEl, meta, fill };
  }

  function syncRows(models) {
    const live = new Set(models.map(model => model.id));

    for (const [id, refs] of S.ui.rows) {
      if (!live.has(id)) {
        refs.row.remove();
        S.ui.rows.delete(id);
      }
    }

    for (const model of models) {
      let refs = S.ui.rows.get(model.id);
      if (!refs) {
        refs = createRow(model.label);
        S.ui.rows.set(model.id, refs);
      }

      if (refs.labelEl.textContent !== model.label) refs.labelEl.textContent = model.label;
      if (refs.meta.textContent !== model.meta) refs.meta.textContent = model.meta;
      setFill(refs.fill, model.percent);

      S.ui.panel.appendChild(refs.row);
    }
  }

  function quotaMeta(percent, resetsAt) {
    const reset = fmtReset(resetsAt);
    return reset ? `${clamp(percent)}% · ${reset}` : `${clamp(percent)}%`;
  }

  function creditModel(data) {
    const spend = data?.spend;
    const extra = data?.extra_usage;
    if (!spend && !extra) return null;

    const percent = clamp(spend?.percent ?? extra?.utilization);
    let used = '';
    let limit = '';

    if (spend) {
      const usedMoney = spend.used;
      const limitMoney = spend.limit ?? spend.cap?.money;
      used = money(usedMoney?.amount_minor, usedMoney?.currency, usedMoney?.exponent);
      limit = money(limitMoney?.amount_minor, limitMoney?.currency, limitMoney?.exponent);
    }

    if ((!used || !limit) && extra) {
      const currency = extra.currency;
      const exponent = extra.decimal_places ?? 2;
      used ||= money(extra.used_credits, currency, exponent);
      limit ||= money(extra.monthly_limit, currency, exponent);
    }

    const enabled = spend?.enabled ?? extra?.is_enabled;
    const disabledReason = spend?.disabled_reason ?? extra?.disabled_reason;
    const parts = [];

    if (used && limit) parts.push(`${used} / ${limit}`);
    else if (used) parts.push(used);
    parts.push(`${percent}%`);

    if (enabled === false) {
      parts.push(disabledReason ? `Disabled: ${titleCase(disabledReason)}` : 'Disabled');
    }

    return {
      id: 'extra_usage_credits',
      label: 'Extra Usage Credits',
      percent,
      meta: parts.join(' · '),
    };
  }

  function collectRows(data) {
    const rows = [];
    const seen = new Set();

    const addQuota = (id, label, value) => {
      const percent = percentOf(value);
      if (percent === null || seen.has(id)) return;
      seen.add(id);
      rows.push({
        id,
        label,
        percent: clamp(percent),
        meta: quotaMeta(percent, value?.resets_at),
      });
    };

    for (const [key, label] of PRIMARY_ROWS) {
      addQuota(key, label, data?.[key]);
    }

    const limitAliases = {
      session: 'five_hour',
      weekly_all: 'seven_day',
      weekly_opus: 'seven_day_opus',
      weekly_sonnet: 'seven_day_sonnet',
      weekly_cowork: 'seven_day_cowork',
      weekly_oauth_apps: 'seven_day_oauth_apps',
    };

    if (Array.isArray(data?.limits)) {
      data.limits.forEach((limit, index) => {
        const alias = limitAliases[limit?.kind];
        const id = alias ?? `limit:${limit?.kind ?? index}:${limit?.scope ?? ''}`;
        addQuota(
          id,
          LIMIT_LABELS[limit?.kind] ?? titleCase(limit?.kind ?? `Limit ${index + 1}`),
          limit
        );
      });
    }

    for (const [key, value] of Object.entries(data ?? {})) {
      if (
        RESERVED.has(key) ||
        !value ||
        typeof value !== 'object' ||
        Array.isArray(value)
      ) continue;

      addQuota(`field:${key}`, titleCase(key), value);
    }

    const credits = creditModel(data);
    if (credits) rows.push(credits);

    addQuota('extra_usage:daily', 'Extra Usage (Daily)', data?.extra_usage?.daily);
    addQuota('extra_usage:weekly', 'Extra Usage (Weekly)', data?.extra_usage?.weekly);

    return rows;
  }

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

    const rows = collectRows(data);
    const session = rows.find(row => row.id === 'five_hour')
      ?? rows.find(row => row.id === 'limit:session:')
      ?? rows[0];

    setFill(S.ui.tf, session?.percent ?? 0);
    syncRows(rows);
  }

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

  function findAnchor() {
    const ed = document.querySelector('[contenteditable="true"].tiptap');
    if (!ed) return null;

    const fs = ed.closest('fieldset');
    if (!fs) return null;

    let anchor = ed;
    while (anchor.parentElement && anchor.parentElement !== fs) {
      anchor = anchor.parentElement;
    }

    return anchor || fs;
  }

  // Cowork 的 footer 在 fieldset 外面。
  // 保持原 anchor,只补偿 footer 超出输入框底部的高度。
  function syncOffset() {
    if (!S.anchor || !S.ui) return;

    const fs = S.anchor.parentElement;
    const main = fs?.parentElement?.parentElement;
    const tray = main?.nextElementSibling;

    if (tray?.getAttribute('aria-hidden') === 'false') {
      const margin = parseFloat(getComputedStyle(tray).marginTop) || 0;
      const offset = Math.max(0, tray.scrollHeight + margin);
      S.ui.root.style.top = `calc(100% + ${offset}px)`;
    } else {
      S.ui.root.style.top = '';
    }
  }

  function attach() {
    // Fast path: our bar is already mounted inside a still-connected anchor.
    if (S.anchor?.isConnected) {
      const cur = document.getElementById(ID);
      if (cur && S.anchor.contains(cur)) {
        syncOffset();
        return;
      }
    }

    const anchor = findAnchor();
    if (!anchor) return;

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

    existing?.remove();

    anchor.classList.add(A);
    if (getComputedStyle(anchor).position === 'static') {
      anchor.style.position = 'relative';
    }

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

    syncOffset();
    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() {
    if (S.poll) clearTimeout(S.poll);
    S.poll = 0;
  }

  function hooks() {
    const patch = (method) => {
      const original = history[method];

      history[method] = function () {
        const result = original.apply(this, arguments);
        scheduleAttach();
        return result;
      };
    };

    patch('pushState');
    patch('replaceState');

    addEventListener('popstate', scheduleAttach, { passive: true });
    addEventListener('hashchange', scheduleAttach, { passive: true });

    let timer = 0;

    S.mo = new MutationObserver(() => {
      if (timer) return;

      timer = setTimeout(() => {
        timer = 0;
        scheduleAttach();
      }, 200);
    });

    S.mo.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['aria-hidden'],
    });

    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();
})();