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

  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 ROWS = [
    ['five_hour',      'Current Session'],
    ['seven_day',      'Weekly Limit (All)'],
    ['seven_day_opus', 'Weekly Limit (Opus)'],
  ];

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

  const clamp = (v) => (v = +v || 0) < 0 ? 0 : v > 100 ? 100 : v;
  const fmt = (iso) => {
    if (!iso) return 'N/A';
    const m = Math.round((new Date(iso).getTime() - Date.now()) / 60000);
    if (m < 1) return 'Resetting soon';
    if (m < 60) return `In ${m} min`;
    const h = (m / 60) | 0;
    return h < 24 ? `In ${h} hr` : `In ${((h / 24) | 0)} days`;
  };

  const jget = (u) => fetch(u, { credentials: 'include' }).then(r => { if (!r.ok) throw new Error(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 d = await jget(`${API}/${id}/usage`);
        if (d) { S.last = d; S.lastAt = Date.now(); }
        return S.last;
      } catch (e) {
        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:auto 16px -15px;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;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;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}
#${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}
@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 sp = '' + p;
    if (el.dataset.p !== sp) {
      el.dataset.p = sp;
      el.style.width = sp + '%';
      const c = clsFor(p);
      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">` +
      ROWS.map(([, label], i) =>
        `<div class="r" data-i="${i}">` +
          `<div class="hh"><span class="l">${label}</span><span class="m" data-role="m"></span></div>` +
          `<div class="k"><div class="f" data-role="f"></div></div>` +
        `</div>`
      ).join('') +
      `</div>`;

    const tf = root.querySelector('[data-role="tf"]');
    const rEls = [...root.querySelectorAll('.r')];
    const metas = rEls.map(r => r.querySelector('[data-role="m"]'));
    const fills = rEls.map(r => r.querySelector('[data-role="f"]'));

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

    return { root, tf, rEls, metas, fills };
  }

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

    setFill(S.ui.tf, clamp(d?.five_hour?.utilization));

    for (let i = 0; i < ROWS.length; i++) {
      const key = ROWS[i][0];
      const b = d?.[key];
      const row = S.ui.rEls[i];
      if (!b) { row.hidden = true; continue; }
      row.hidden = false;

      const p = clamp(b.utilization);
      setFill(S.ui.fills[i], p);

      const t = `${p}% · ${fmt(b.resets_at)}`;
      const m = S.ui.metas[i];
      if (m.dataset.t !== t) { m.dataset.t = t; m.textContent = t; }
    }
  }

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

  function findAnchor() {
    const ed = document.querySelector('[contenteditable="true"].tiptap');
    if (!ed) return null;
    const fs = ed.closest('fieldset');
    if (!fs) return null;
    return fs.querySelector('div[class*="bg-bg-000"][class*="rounded-[20px]"]') || fs;
  }

  function attach() {
    const a = findAnchor();
    if (!a) return;

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

    existing && existing.remove();

    a.classList.add(A);
    if (getComputedStyle(a).position === 'static') a.style.position = 'relative';

    S.anchor = a;
    S.ui = build();
    a.insertBefore(S.ui.root, a.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() { S.poll && clearTimeout(S.poll); S.poll = 0; }

  function hooks() {
    const patch = (m) => {
      const o = history[m];
      history[m] = function () { const r = o.apply(this, arguments); scheduleAttach(); return r; };
    };
    patch('pushState'); patch('replaceState');
    addEventListener('popstate', scheduleAttach, { passive: true });
    addEventListener('hashchange', scheduleAttach, { passive: true });

    let t = 0;
    S.mo = new MutationObserver(() => {
      if (t) return;
      t = setTimeout(() => { t = 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();
})();