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