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      3.0.2
// @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';

  // Guard against double-injection (avoids duplicate observers / timers / history patches).
  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}
[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);

      // appendChild also moves an existing row, keeping API-defined ordering stable.
      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),
      });
    };

    // Stable, human-friendly ordering for Claude's known quota fields.
    for (const [key, label] of PRIMARY_ROWS) addQuota(key, label, data?.[key]);

    // Newer API responses may expose normalized limits instead of, or in addition to,
    // the legacy top-level fields. Skip obvious duplicates.
    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);
      });
    }

    // Include any future/non-null top-level quota object carrying utilization/percent.
    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);

    // Some responses may provide separate daily/weekly extra-usage buckets.
    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;
  }

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

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

    const existing = document.getElementById(ID);
    if (anchor === S.anchor && existing && anchor.contains(existing)) 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);

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

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