Claude Inline Usage Tracker

Minimal usage bar below Claude input

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Вам потребуется установить расширение, например Tampermonkey или Violentmonkey, чтобы установить этот скрипт.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

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