Claude Inline Usage Tracker

Minimal usage bar below Claude input

Bu betiği kurabilmeniz için Tampermonkey, Greasemonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

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

Bu betiği kurabilmeniz için Tampermonkey ya da Violentmonkey gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği kurabilmeniz için Tampermonkey ya da Userscripts gibi bir kullanıcı betiği eklentisini kurmanız gerekmektedir.

Bu betiği indirebilmeniz için ayrıca Tampermonkey gibi bir eklenti kurmanız gerekmektedir.

Bu betiği yüklemek için bir betik yöneticisi eklentisi yüklemeniz gerekecektir.

(Zaten bir betik yöneticim var, hadi yükleyelim!)

Bu stili yüklemek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için Stylus gibi bir uzantı kurmanız gerekir.

Bu stili yükleyebilmek için Stylus gibi bir uzantı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

Bu stili yüklemek için bir kullanıcı stili yöneticisi uzantısı kurmanız gerekir.

Bu stili yükleyebilmek için bir kullanıcı stili yöneticisi uzantısı yüklemeniz gerekir.

(Zateb bir user-style yöneticim var, yükleyeyim!)

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