Last active
October 20, 2026 23:49
-
-
Save iTrooz/bb6abf25068cccc5ceda04a97502833d to your computer and use it in GitHub Desktop.
Display usage limits in the sidebar in ChatGPT web
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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