Minimal usage bar below Claude input
// ==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();
})();