Claude Inline Usage Tracker

Minimal usage bar below Claude input

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 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();
})();