Skip to content

Instantly share code, notes, and snippets.

@iTrooz
Last active October 20, 2026 23:49
Show Gist options
  • Select an option

  • Save iTrooz/bb6abf25068cccc5ceda04a97502833d to your computer and use it in GitHub Desktop.

Select an option

Save iTrooz/bb6abf25068cccc5ceda04a97502833d to your computer and use it in GitHub Desktop.
Display usage limits in the sidebar in ChatGPT web
// ==UserScript==
// @name ChatGPT Sidebar Limits (local)
// @namespace local.chatgpt.sidebar-limits
// @version 1.3.1
// @description Five-hour and weekly remaining usage, with visible diagnostics.
// @match https://chatgpt.com/*
// @run-at document-idle
// @sandbox raw
// @inject-into page
// @grant none
// @updateURL none
// @downloadURL none
// ==/UserScript==
(() => {
'use strict';
const KEY = '__localChatGPTLimits';
window[KEY]?.stop();
const PERIOD = 120_000;
let stopped = false, busy = false, controller, lastAttempt = 0;
let windows = [], updated = 0, error = '';
const box = document.createElement('section');
box.id = 'local-chatgpt-limits';
box.setAttribute('aria-label', 'Usage limits');
const style = document.createElement('style');
style.textContent = `
#local-chatgpt-limits {box-sizing:border-box; margin:8px; padding:10px;
border:1px solid #8885; border-radius:10px; font:12px/1.4 system-ui;
color:var(--limit-text,inherit); background:var(--color-surface,var(--sidebar-surface-primary,#212121));
min-width:150px; text-align:left; flex-shrink:0; z-index:9999}
#local-chatgpt-limits[data-floating] {position:fixed;left:10px;bottom:70px;width:220px}
#local-chatgpt-limits[data-integrated] {margin:4px 12px 8px;padding:8px 10px;min-width:0;
border:0;border-bottom:1px solid #8883;border-radius:0;background:transparent}
#local-chatgpt-limits[data-integrated] .limit-content {display:grid;grid-template-columns:1fr 1fr;gap:0 12px}
#local-chatgpt-limits[data-integrated] .limit-content > small {grid-column:1/-1}
#local-chatgpt-limits[data-integrated] .limit-row strong {font-size:12px}
#local-chatgpt-limits button {color:inherit;background:transparent;border:1px solid #8886;
border-radius:5px;cursor:pointer;padding:2px 6px;font:inherit}
#local-chatgpt-limits .limit-head {display:flex;justify-content:space-between;align-items:center}
#local-chatgpt-limits .limit-row {margin-top:8px}
#local-chatgpt-limits strong {color:inherit}
#local-chatgpt-limits .limit-bar {width:100%;height:6px;display:block;margin-top:3px;
border-radius:999px;overflow:hidden;background:color-mix(in srgb,currentColor 20%,transparent)}
#local-chatgpt-limits .limit-fill {height:100%;display:block;border-radius:999px;background:#10a37f}
#local-chatgpt-limits small {display:block;opacity:.75;white-space:normal}
#local-chatgpt-limits .limit-error {color:#f99;overflow-wrap:anywhere;margin-top:6px}
`;
const head = document.createElement('div');
head.className = 'limit-head';
const title = document.createElement('strong');
title.textContent = 'Usage remaining';
const refreshButton = document.createElement('button');
refreshButton.textContent = '↻';
refreshButton.title = 'Refresh usage';
refreshButton.type = 'button';
const content = document.createElement('div');
content.className = 'limit-content';
head.append(title, refreshButton);
box.append(head, content);
document.head.append(style);
function visible(el) {
const r = el.getBoundingClientRect();
return r.width >= 170 && r.height > 50 && getComputedStyle(el).visibility !== 'hidden';
}
function mount() {
const nativeNav = document.querySelector('#app-shell-sidebar .sidebar-navigation nav[aria-label="Chat history"]')
|| document.querySelector('#app-shell-sidebar nav');
if (nativeNav?.parentElement) {
const reference = nativeNav.querySelector('a[href="/"], a[href^="/c/"], a');
if (reference) box.style.setProperty('--limit-text', getComputedStyle(reference).color);
box.dataset.integrated = '';
delete box.dataset.floating;
if (box.parentElement !== nativeNav.parentElement || box.nextSibling !== nativeNav) {
nativeNav.before(box);
}
return;
}
delete box.dataset.integrated;
// Avoid depending on profile-button IDs or another extension's classes.
const candidates = [...document.querySelectorAll(
'#stage-slideover-sidebar, [data-testid="sidebar"], aside, nav[aria-label], nav'
)].filter(visible);
const target = candidates.find(el => el.querySelector('a[href="/"], a[href^="/c/"], a[href^="/g/"]'))
|| candidates.find(el => /sidebar|chat history/i.test(el.getAttribute('aria-label') || el.id));
if (target) {
delete box.dataset.floating;
if (box.parentElement !== target) target.prepend(box);
} else {
box.dataset.floating = '';
if (box.parentElement !== document.body) document.body.append(box);
}
}
function parseWindows(payload) {
// Prefer primary account limits; never substitute an unrelated feature's quota.
const rate = payload?.rate_limit ?? payload?.data?.rate_limit ?? payload?.usage?.rate_limit;
if (!rate) throw new Error('Usage response has no account rate_limit field.');
return [18_000, 604_800].map(seconds => {
const w = [rate.primary_window, rate.secondary_window].find(
value => Number(value?.limit_window_seconds) === seconds
);
if (!w || w.used_percent == null || !Number.isFinite(Number(w.used_percent))) return null;
const reset = Number(w.reset_at);
const after = Number(w.reset_after_seconds);
return {
label: seconds === 18_000 ? '5 hours' : '7 days',
remaining: Math.max(0, Math.min(100, 100 - Number(w.used_percent))),
durationMs: seconds * 1000,
reset: w.reset_at != null && Number.isFinite(reset) ? reset * 1000
: w.reset_after_seconds != null && Number.isFinite(after) ? Date.now() + after * 1000 : null,
};
});
}
function linearRemaining(w, now = Date.now()) {
if (!Number.isFinite(w.reset) || !(w.durationMs > 0)) return null;
return Math.max(0, Math.min(100, (w.reset - now) / w.durationMs * 100));
}
function countdown(timestamp) {
if (!timestamp) return 'Reset time unavailable';
const minutes = Math.max(0, Math.ceil((timestamp - Date.now()) / 60_000));
if (!minutes) return 'Reset due; refresh to check';
const days = Math.floor(minutes / 1440), hours = Math.floor(minutes / 60) % 24;
return `Resets in ${days ? days + 'd ' : ''}${hours}h ${minutes % 60}m`;
}
function render() {
content.replaceChildren();
['5 hours', '7 days'].forEach((label, index) => {
const w = windows[index];
const row = document.createElement('div');
row.className = 'limit-row';
const text = document.createElement('strong');
text.textContent = `${label}: ${w ? Math.round(w.remaining * 10) / 10 + '%' : '—'}`;
row.append(text);
if (w) {
const bar = document.createElement('div');
bar.className = 'limit-bar';
bar.setAttribute('role', 'progressbar');
bar.setAttribute('aria-valuemin', '0');
bar.setAttribute('aria-valuemax', '100');
bar.setAttribute('aria-valuenow', String(w.remaining));
const fill = document.createElement('span');
fill.className = 'limit-fill';
fill.style.width = `${w.remaining}%`;
bar.append(fill);
bar.setAttribute('aria-label', `${label} remaining`);
const reset = document.createElement('small');
reset.textContent = countdown(w.reset);
if (w.reset) reset.title = new Date(w.reset).toLocaleString();
const pace = document.createElement('small');
const target = linearRemaining(w);
pace.textContent = target == null ? 'Linear target: —' : `Linear target: ${Math.round(target * 10) / 10}%`;
pace.title = 'Remaining percentage at a steady rate from 100% at the start of this window to 0% at reset.';
row.append(bar, pace, reset);
}
content.append(row);
});
const status = document.createElement('small');
status.textContent = busy ? 'Refreshing…' : 'Waiting for usage data';
if (busy || !updated) content.append(status);
if (error) {
const message = document.createElement('small');
message.className = 'limit-error';
message.textContent = `${updated ? 'Showing old values. ' : ''}${error}`;
content.append(message);
}
refreshButton.disabled = busy;
}
async function getJson(path, headers = {}) {
const response = await fetch(path, {credentials:'include', cache:'no-store', headers,
signal:controller.signal, redirect:'error'});
if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
try { return await response.json(); }
catch { throw new Error(`${path}: expected JSON; try reloading ChatGPT.`); }
}
async function refresh(force = false) {
if (stopped || busy || (!force && Date.now() - lastAttempt < PERIOD)) return;
busy = true; error = ''; lastAttempt = Date.now(); render();
controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20_000);
try {
const session = await getJson('/api/auth/session');
const token = session?.accessToken ?? session?.access_token;
const headers = {accept:'application/json'};
if (typeof token === 'string' && token) headers.authorization = `Bearer ${token}`;
const payload = await getJson('/backend-api/wham/usage', headers);
const parsed = parseWindows(payload);
if (!parsed.some(Boolean)) throw new Error('This account did not return 5-hour or 7-day quotas.');
if (stopped) return;
windows = parsed; updated = Date.now();
} catch (e) {
if (!stopped) error = e.name === 'AbortError' ? 'Request timed out. Click ↻ to retry.' : e.message;
} finally {
clearTimeout(timeout); busy = false;
if (!stopped) render();
}
}
const onVisibility = () => { if (!document.hidden) { mount(); refresh(); } };
refreshButton.addEventListener('click', () => refresh(true));
document.addEventListener('visibilitychange', onVisibility);
const mountTimer = setInterval(mount, 3000);
const refreshTimer = setInterval(() => { if (!document.hidden) refresh(); }, PERIOD);
const countdownTimer = setInterval(render, 30_000);
window[KEY] = {stop() {
stopped = true; controller?.abort();
[mountTimer, refreshTimer, countdownTimer].forEach(clearInterval);
document.removeEventListener('visibilitychange', onVisibility);
box.remove(); style.remove(); delete window[KEY];
}};
mount(); render(); refresh(true);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment