adom-vscode
Public Made by Adomby adom
Let Claude, Codex and Kimi drive VS Code inside your Adom container
Usage widgets: hover shows when each limit resets (countdown ring, reset time, pace) #1
drew/usage-hover-reset-meter → masterDrew's ask (2026-10-12): "We should show beautiful, human-facing details on hover. I am especially craving a meter to show time remaining until my usage limits reset."
Both usage endpoints already return a reset time for every limit (Claude: limits[].resets_at; Codex: primary_window.reset_at and limit_window_seconds), so the countdown is exact and costs no extra calls.
What it looks like
Hovering Claude usage or Codex usage opens a live view inside Hydrogen's card, through the existing hdWidget.expand (Hydrogen 1.0.429+). For each limit:
- A ring that drains until the reset. Its centre counts down (
27m 35s,4d 13h) on the page's own clock, once a second and only while the view is showing; the provider is not asked again. - Percent used with a bar, plus a tick where an even pace through the window would be. A bar past its tick is burning faster than the window.
- "Resets in 27 min · 1:10 PM", in the viewer's time zone (today, tomorrow, weekday, or a date beyond a week).
- One sentence on pace: "On pace for about 90% by the reset", "Plenty of room: on pace for about 63%", in amber "At this pace you hit the limit around 3:05 PM, 6 days 17 h before it resets", or in red "Limit reached. You are back at 1:10 PM." Pace is use so far over the share of the window gone by, and it stays quiet for the first 30 minutes of a window.
Below the limits: usage credits when the Claude account has them turned on (spend), how fresh the numbers are, and the view the slot is in. Provider errors keep the dimmed last numbers and show the reason in a banner with a Check now button (POST /refresh). When a window rolls over, the page asks once for fresh numbers instead of showing the old window's percent. Pictures follow in a comment, and on the widgets page.
Changes
bin/hydrogen-widget-claude-usage,bin/hydrogen-widget-codex-usage: each item also carriesname(Current session, This week, all models, Fable this week; This week or N-hour window for Codex),resets_at(Unix seconds) andwindow_s. The Claude feed addscreditsfromspend.label,pctandseverityare unchanged, so every slot view draws as before. Codex now sayscriticalwhenrate_limit.limit_reached.bin/hydrogen-widget-lib.py:- The view.
X_CSSandX_JSare filled intoPAGEas values, so they need no%%escaping. The page switches layout on its own height (100 px and up), postsexpand {width: 380, height}measured on a hidden clone, re-posts on load, withdraws (expand: null) when it has nothing to show, and forwards Escape ascollapse. - Slot fixes.
postWidthdoes nothing while expanded, because the hidden slot layout measured 0 and the slot shrank. The slot's browsertitletooltip is dropped inside Hydrogen, because it sat on top of the card (Drew's screenshot); standalone pages keep it. - Server. It stamps
at, the time of the last good fetch. - Preview.
/preview?x=1shows the view alone, which is how the docs pictures were made. - A widget on this helper whose items have no
resets_atorwindow_sgets the bars and names without rings.
- The view.
hydrogen-widget.json: the briefs are written for a person and mention the hover view;aboutdescribes it.widgets/README.md: new section "Hover for when your limits reset" with two pictures (widgets/docs/*-hover.png, 3x, from/preview?x=1). The two paragraphs that pointed at the old tooltip now point at the view.README.md: one changelog line, "Next (Drew Owens, PR)". I leftVERSIONalone for your release.
Verified
- Live on Drew's Hydrogen (build 64a2e88): with the slot pointed at a test server running this branch, a pinned card and a simulated hover both show the view with real numbers. The slot keeps its 183 px and shows "Expanded below".
- Headless Chrome: the slot layout (
#w) renders unchanged at 38 px tall. The view renders with real data for both providers, and in the no-sign-in error state ("Sign in to Codex in this workspace" + Check now). - The pace and countdown text were unit-checked on nine cases: limit reached, ahead of pace, on pace, plenty, just started, nothing used, no reset info, reset just passed, and hitting the limit right at the reset.
On today's card this view sits above the old metadata block. adom/hydrogen-dev PR #4 makes the card lead with it and folds the rest under Details.
Diff Skip to comments (3)
README.md+2@@ -281,6 +281,8 @@ v1.1.20: /command returns errorCode command_not_found (HTTP 400) with did-you-me v1.1.21: event-driven tab census + Claude session identity (John 2026-09-03: never poll the iframes again). GET /tabs and the SSE stream GET /tabs/events publish the extension host's own tabGroups census on every change; POST /claude/session-start ingests the Claude SessionStart hook (id + transcript path, before the jsonl is flushed) and GET /claude/sessions lists it; POST /claude/open-session opens an existing conversation by id and confirms the bind (retagging sdk-cli transcripts, which the Claude extension otherwise declines). Capability markers in /health: tabs.list, tabs.events, claude.open-session, claude.session-start, claude.sessions. +Next (Drew Owens, PR): the usage widgets' hover card shows when each limit resets. Through Hydrogen's `hdWidget.expand`, the card opens a live view: a ring per limit that drains until the reset (counting down on the page's own clock, no extra provider calls), the percent used with an even-pace tick on the bar, the reset time in the viewer's time zone, and one sentence on whether the current pace hits the limit first; usage credits when the Claude account has them on; provider errors in plain words with a Check now button. The data feeds now carry `name`, `resets_at` and `window_s` per limit; the slot drops its browser tooltip inside Hydrogen (it covered the card); `/preview?x=1` pictures the view. Pairs with the adom/hydrogen-dev PR that makes the card itself human-first.+ v1.1.93 (Kyle Bergstedt, PRs #2 and #3): the local-workspace detector is no longer WSL2-only — `/etc/profile.d/hydrogen-env.sh` and `ADOM_HYDROGEN_MACHINE` count as `kind:"hd"` alongside `hd-env.sh` and the WSL kernel, so the macOS Hydrogen workspace machine (systemd-nspawn inside Lima) stops reporting `kind:"unknown"` and stops refusing exec/fs.write; both new markers are ignored when `VSCODE_PROXY_URI` is `.adom.cloud`, and `markers` gains `hydrogenEnvSh`/`hydrogenMachine`. CLI: `ai providers|new|icons` and `tabs list|close` subcommands exist (routes and skill had them, the clap enum did not); a policy 403 prints the server's own error/hint instead of "install, then reload", and `exec --json` exits non-zero on `ok:false`. The Codex Sessions side bar matches VS Code's own list density (editor tokens, keyboard access). Docs: platform wording corrected (Windows: labels with the macOS path beside), dead wiki host replaced. v1.1.82: the usage widgets live on 27011 and 27012, well away from the 8000s where apps land (John 2026-09-15); a widget is a side panel and must never take a port an app wants.
bin/hydrogen-widget-claude-usage+32−9@@ -8,6 +8,16 @@ from importlib.machinery import SourceFileLoader marks = SourceFileLoader('hwmarks', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-marks.py')).load_module() lib = SourceFileLoader('hwlib', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-lib.py')).load_module() +SESSION_S, WEEK_S = 5 * 3600, 7 * 86400++def epoch(iso):+ """The provider's ISO reset time as Unix seconds (None when absent or unreadable)."""+ if not iso: return None+ try:+ from datetime import datetime+ return round(datetime.fromisoformat(str(iso).replace('Z', '+00:00')).timestamp())+ except Exception: return None+ def data(): if not os.path.exists(os.path.expanduser('~/.claude/.credentials.json')): return {'error': 'sign in to Claude Code in this workspace'}@@ -25,24 +35,37 @@ def data(): # The structured list is what Claude Code's own Usage panel draws: the session window, the # weekly window, and every weekly limit scoped to a model (Fable, Opus, ...), each with the # provider's severity. Fall back to the flat fields on an older response.+ # Every limit also carries when it resets and how long its window is, so the hover view can+ # draw a countdown and say whether the current pace runs out before the reset (Drew+ # 2026-10-12: "a meter to show time remaining until my usage limits reset"). for lim in u.get('limits') or []: kind = lim.get('kind'); pct = lim.get('percent') if pct is None: continue- if kind == 'session': label = '5h'- elif kind == 'weekly_all': label = 'week'+ if kind == 'session': label, name, win = '5h', 'Current session', SESSION_S+ elif kind == 'weekly_all': label, name, win = 'week', 'This week, all models', WEEK_S elif kind == 'weekly_scoped': scope = lim.get('scope') or {}; model = scope.get('model') or {} label = model.get('display_name') or 'model'- else: label = kind or 'limit'- bars.append({'label': label, 'pct': pct, 'severity': lim.get('severity') or 'normal'})+ name, win = label + ' this week', WEEK_S+ else: label, name, win = kind or 'limit', (kind or 'limit').replace('_', ' ').capitalize(), 0+ bars.append({'label': label, 'pct': pct, 'severity': lim.get('severity') or 'normal',+ 'name': name, 'resets_at': epoch(lim.get('resets_at')), 'window_s': win}) if not bars:- for label, key in (('5h', 'five_hour'), ('week', 'seven_day')):- v = (u.get(key) or {}).get('utilization')- if v is not None: bars.append({'label': label, 'pct': v, 'severity': 'normal'})+ for label, key, name, win in (('5h', 'five_hour', 'Current session', SESSION_S), ('week', 'seven_day', 'This week, all models', WEEK_S)):+ v = u.get(key) or {}+ if v.get('utilization') is not None:+ bars.append({'label': label, 'pct': v['utilization'], 'severity': 'normal', 'name': name, 'resets_at': epoch(v.get('resets_at')), 'window_s': win}) for k, v in u.items(): if k.startswith('seven_day_') and isinstance(v, dict) and v.get('utilization') is not None:- bars.append({'label': k[len('seven_day_'):].replace('_', ' '), 'pct': v['utilization'], 'severity': 'normal'})- return {'items': bars}+ label = k[len('seven_day_'):].replace('_', ' ')+ bars.append({'label': label, 'pct': v['utilization'], 'severity': 'normal', 'name': label.capitalize() + ' this week', 'resets_at': epoch(v.get('resets_at')), 'window_s': WEEK_S})+ out = {'items': bars}+ # Usage credits (the paid overflow past the plan limits), when the account has them turned on.+ sp = u.get('spend') or {}+ if sp.get('enabled') and isinstance(sp.get('limit'), dict):+ def money(m): return (m.get('amount_minor') or 0) / (10 ** (m.get('exponent') or 0))+ out['credits'] = {'used': money(sp.get('used') or {}), 'limit': money(sp['limit']), 'currency': (sp['limit'].get('currency') or 'USD')}+ return out RENDER = r"""(d.error?`<span class="err">${d.error}</span>`:(d.items||[]).map(b=>bar(b.label,b.pct,b.severity)).join(''))""" lib.serve('Claude usage', data, RENDER, 180000, mark=marks.MARKS['claude'], default_view='nums')
bin/hydrogen-widget-codex-usage+12−3@@ -2,7 +2,7 @@ """Codex usage in the title bar: the plan's rate-limit window as percent used, plus a secondary window when the plan has one. Reads the Codex sign-in in ~/.codex/auth.json and asks the usage endpoint the Codex CLI shows in /status. Asks the provider at most once every three minutes, pauses while the window is hidden, retries at 5, 15, 45 s on errors. Click the slot to cycle the views (rows, one number, a ring, the pair, both as numbers or rings)."""-import json, os, sys+import json, os, sys, time from importlib.machinery import SourceFileLoader marks = SourceFileLoader('hwmarks', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-marks.py')).load_module() lib = SourceFileLoader('hwlib', os.path.join(os.path.dirname(os.path.realpath(__file__)), 'hydrogen-widget-lib.py')).load_module()@@ -20,8 +20,17 @@ def data(): def label(w): secs = w.get('limit_window_seconds') or 0 return 'week' if secs >= 6*86400 else ('5h' if secs >= 4*3600 else f"{max(1, secs//3600)}h")- items = [{'label': label(p), 'pct': p.get('used_percent', 0) or 0, 'severity': 'normal'}]- if s: items.append({'label': label(s), 'pct': s.get('used_percent', 0) or 0, 'severity': 'normal'})+ def name(w):+ secs = w.get('limit_window_seconds') or 0+ return 'This week' if secs >= 6*86400 else f"{max(1, round(secs/3600))}-hour window"+ def item(w):+ # reset_at (Unix seconds) and the window length feed the hover view's countdown meter.+ at = w.get('reset_at')+ if not at and w.get('reset_after_seconds') is not None: at = round(time.time() + w['reset_after_seconds'])+ return {'label': label(w), 'pct': w.get('used_percent', 0) or 0, 'severity': 'critical' if rl.get('limit_reached') else 'normal',+ 'name': name(w), 'resets_at': at, 'window_s': w.get('limit_window_seconds') or 0}+ items = [item(p)]+ if s: items.append(item(s)) return {'plan': u.get('plan_type'), 'items': items} RENDER = r"""(d.error?`<span class="err">${d.error}</span>`:(d.items||[]).map(b=>bar(b.label,b.pct,b.severity)).join(''))"""
bin/hydrogen-widget-lib.py+164−11@@ -82,7 +82,9 @@ html,body{margin:0;height:100%%;background:transparent;color:#e6ebef;font-family .rw .cell .lbl{font-size:7px} } @media (max-height:33px){ .err{overflow:hidden;text-overflow:ellipsis;max-width:100%%} }+%(x_css)s </style><body><div class="w" id="wrap">%(mark_html)s<div class="rows" id="w">%(title)s…</div></div>+<div class="x" id="x"></div> <script> const R=%(refresh_ms)d; function sev(p,s){return (s==='critical'||p>=90)?'hot':(s==='warning'||p>=70)?'warn':'';}@@ -104,6 +106,7 @@ function viewsFor(items){const n=items.length;const v=[{id:'rows',name:'all mete one big number, the same look: 'nums' needs two items, and falling to the first view (rows) showed Codex's lone week as a thin meter (John 2026-10-01). */ const DEF_VIEW='%(default_view)s';+let viewName=''; let curView=(function(){try{return localStorage.getItem('hw.view')||DEF_VIEW;}catch(e){return DEF_VIEW;}})(); function renderItems(items,id){const [kind,arg]=String(id).split(':');const at=(k)=>items[Math.max(0,Math.min(items.length-1,+k||0))]; switch(kind){@@ -122,7 +125,12 @@ function render(d0){const d=d0&&d0.stale?d0.stale:d0;const el=document.getElemen el.classList.toggle('hz',curView!=='rows');el.innerHTML=renderItems(d.items,curView); tip=(tip?tip+'. ':'')+'%(title)s: '+vs[k].name+'. Click for the next view ('+(k+1)+' of '+vs.length+').';} else{el.classList.remove('hz');el.innerHTML=%(render_js)s;tip=(d0&&d0.error?String(d0.error)+'. ':'')+'Click to check again now.';}- wrap.classList.toggle('act',!!(d0&&d0.error));wrap.title=tip;postWidth();}+ wrap.classList.toggle('act',!!(d0&&d0.error));+ /* Inside Hydrogen the hover card shows the expanded view below, which says all of this in full+ sentences; the browser tooltip only sat on top of that card (Drew 2026-10-12). Standalone (a+ browser tab, /preview) keeps it. */+ wrap.title=(window.parent===window)?tip:'';viewName=(d&&Array.isArray(d.items)&&d.items.length)?(viewsFor(d.items).find(v=>v.id===curView)||{}).name||'':'';+ postWidth();renderX(d0);} /* The click's own re-render must carry the NEW view: window.__last is the last poll's copy, whose view field still names the old one, and render() adopts the server's view over the local one. Without this line a click changed the server and nothing on screen until the next poll, three@@ -147,6 +155,8 @@ document.getElementById('wrap').addEventListener('click',()=>{const d0=window.__ out at max-content, so the slot's current width never feeds back into the answer (rows stretch to the slot, so measuring the live boxes would only ever grow). Works for any markup, not just rows. */ function postWidth(){try{const w=document.querySelector('.w');if(!w)return;+ /* Moved into the hover card, the slot layout is hidden and measures 0: keep the slot's width. */+ if(window.innerHeight>=100)return; const c=w.cloneNode(true);c.querySelectorAll('[id]').forEach(e=>e.removeAttribute('id')); c.style.cssText='position:absolute;left:-10000px;top:0;width:max-content;height:100vh;visibility:hidden;pointer-events:none'; document.body.appendChild(c);const total=Math.ceil(c.getBoundingClientRect().width)+2;c.remove();@@ -167,8 +177,143 @@ tick();if(!document.hidden)arm(); /* The slot resizes when the Title Bar goes compact or tall; the iframe's own window gets the resize event and the height media queries above re-evaluate. Re-render too, for widgets whose render depends on the size. */ let rz=null;window.addEventListener('resize',()=>{clearTimeout(rz);rz=setTimeout(()=>{if(window.__last)render(window.__last);},80);});+%(x_js)s </script></body>""" +# ── The hover view (Drew 2026-10-12: "show beautiful, human-facing details on hover ... a meter to+# show time remaining until my usage limits reset"). Hydrogen 1.0.429+ moves this same frame into+# the widget's hover card when the page asks for it ({hdWidget:{expand:{width,height}}}), so the+# page swaps layouts on HEIGHT: under 100 px it is the title-bar slot above, at 100 px and up it is+# this view. Per limit: a ring that drains as the window runs out (the time left until it resets),+# the percent used with a bar, a tick on the bar where an even pace through the window would be, the+# reset time in the viewer's own clock, and one sentence on whether the current pace runs out first.+# Widgets whose items carry no resets_at/window_s still get the bars and names, just no ring.+# These two are plain strings (no %-formatting), filled into PAGE as values.+X_CSS = r"""+.x{display:none;box-sizing:border-box;padding:10px 14px 10px;color:#e6ebef;font-size:12px;line-height:1.35;max-height:100vh;overflow-y:auto;scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.18) transparent;cursor:default;user-select:none}+@media (min-height:100px){.w{display:none!important}.x{display:block}}+.xhd{display:flex;align-items:center;justify-content:space-between;gap:9px;font-size:11px;color:rgba(230,235,239,.6)}+.xhd .pl{padding:1px 7px;border-radius:9px;border:1px solid rgba(255,255,255,.14);color:rgba(230,235,239,.8)}+.xl{display:grid;grid-template-columns:54px minmax(0,1fr);gap:12px;align-items:center;padding:10px 0;border-top:1px solid rgba(255,255,255,.07)}+.xl.nr{grid-template-columns:minmax(0,1fr)}+.xhd+.xl,.xe+.xl{border-top:0}+.cd{position:relative;width:54px;height:54px}+.cd svg{width:100%;height:100%;transform:rotate(-90deg);display:block}+.cd circle{fill:none;stroke-width:4.5}+.cd .trk{stroke:rgba(255,255,255,.08)}.cd .arc{stroke:#8c9eff;stroke-linecap:round;transition:stroke-dasharray .8s ease}+.cd.soon .arc{stroke:#19b3a6}+.cd .ct{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;line-height:1}+.cd .ct b{font-size:14px;font-weight:700;font-variant-numeric:tabular-nums;letter-spacing:-.01em}+.cd .ct small{font-size:9px;opacity:.6;margin-top:2px;font-variant-numeric:tabular-nums}+.xh{display:flex;align-items:baseline;justify-content:space-between;gap:8px}+.xn{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.xp{flex:none;font-size:19px;font-weight:700;font-variant-numeric:tabular-nums;color:#19b3a6;line-height:1}+.xp small{font-size:10px;font-weight:500;opacity:.6;margin-left:3px;color:#e6ebef}+.xp.warn{color:#f0a060}.xp.hot{color:#e5484d}+.ub{position:relative;height:7px;margin:7px 0 6px;border-radius:4px;background:rgba(255,255,255,.09)}+.ub i{position:absolute;left:0;top:0;bottom:0;border-radius:4px;background:linear-gradient(90deg,#0f8f86,#19b3a6);transition:width .6s ease}+.ub.warn i{background:linear-gradient(90deg,#c9783f,#f0a060)}.ub.hot i{background:linear-gradient(90deg,#b8323a,#e5484d)}+.ub em{position:absolute;top:-3px;bottom:-3px;width:2px;margin-left:-1px;border-radius:1px;background:#e6ebef;opacity:.7}+.xs{font-size:11px;color:rgba(230,235,239,.72)}+.xs .pc{display:block;margin-top:2px}.xs .pc.warn{color:#f0a060}.xs .pc.hot{color:#e5484d}+.xe{margin:8px 0 2px;padding:7px 9px;border-radius:6px;background:rgba(240,160,96,.1);color:#f3c9a2;font-size:11px;display:flex;align-items:center;gap:8px}+.xe button{font:inherit;font-size:11px;padding:3px 9px;border-radius:5px;border:1px solid rgba(255,255,255,.18);background:rgba(255,255,255,.06);color:#e6ebef;cursor:pointer;flex:none}+.xe button{margin-left:auto}.xe button:hover{border-color:#19b3a6;color:#19b3a6}+.x.stale .xl{opacity:.62}+.xf{margin-top:2px;padding-top:8px;border-top:1px solid rgba(255,255,255,.07);font-size:10.5px;color:rgba(230,235,239,.55);display:flex;flex-direction:column;gap:3px}+.xf .cr{color:rgba(230,235,239,.8)}+"""++X_JS = r"""+/* ── The hover view. Built from the same data the slot draws; the ring and the sentences tick on+ the page's own clock (once a second, only while the view is showing), never by asking again. */+const XW=380;+function esc(v){return String(v==null?'':v).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}+function sevOf(it){return sev(it.pct,it.severity);}+function clock(t){const d=new Date(t*1000),n=new Date();const hm=d.toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});+ const day=x=>new Date(x.getFullYear(),x.getMonth(),x.getDate()).getTime();const dd=Math.round((day(d)-day(n))/86400000);+ if(dd===0)return hm;if(dd===1)return 'tomorrow '+hm;if(dd>1&&dd<7)return d.toLocaleDateString([],{weekday:'short'})+' '+hm;+ return d.toLocaleDateString([],{month:'short',day:'numeric'})+' '+hm;}+function span(s){s=Math.max(0,Math.round(s));const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);+ if(d)return d+' day'+(d>1?'s':'')+(h?' '+h+' h':'');if(h)return h+' h'+(m?' '+m+' min':'');if(m)return m+' min';return s+' s';}+/* The ring's centre: the biggest unit, and the next one under it. */+function ringText(s){s=Math.max(0,Math.round(s));const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);+ if(s<=0)return ['now',''];if(d)return [d+'d',h+'h'];if(h)return [h+'h',m+'m'];if(m)return [m+'m',(s%60)+'s'];return [s+'s',''];}+/* One sentence per limit: has it run out, will the current pace run out before the reset, or how+ much is likely to be used by then. Pace = percent used over the share of the window gone by. */+function paceText(it,now){const at=it.resets_at,W=it.window_s;+ if(it.pct>=100)return {c:'hot',t:'Limit reached. You are back '+(at?(new Date(at*1000).toDateString()===new Date().toDateString()?'at ':'')+clock(at):'when it resets')+'.'};+ if(!at||!W)return null;const left=at-now;if(left<=0)return {c:'',t:'Resetting now.'};+ const gone=Math.max(0,Math.min(1,1-left/W));+ if(it.pct<=0)return {c:'',t:'Nothing used yet in this window.'};+ /* Under half an hour in, a projection is noise. */+ if(gone*W<1800)return {c:'',t:'This window just started.'};+ const proj=it.pct/gone;+ if(proj>=100){const rate=it.pct/(gone*W),t100=now+(100-it.pct)/rate,early=at-t100;+ return {c:early>W*0.02?'warn':'',t:'At this pace you hit the limit around '+clock(t100)+(early>=60?', '+span(early)+' before it resets.':', right as it resets.')};}+ if(proj>=80)return {c:'',t:'On pace for about '+Math.round(proj)+'% by the reset.'};+ return {c:'',t:'Plenty of room: on pace for about '+Math.round(proj)+'%.'};}+function ago(t){if(!t)return '';const s=Date.now()/1000-t;if(s<60)return 'Updated just now';if(s<3600)return 'Updated '+Math.round(s/60)+' min ago';return 'Updated at '+new Date(t*1000).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'});}+function money(v,cur){try{return new Intl.NumberFormat([],{style:'currency',currency:cur||'USD'}).format(v);}catch(e){return '$'+Number(v).toFixed(2);}}+const R_C=2*Math.PI*23;+function limRow(it,i){const c=sevOf(it);const ring=!!(it.resets_at&&it.window_s);const p=Math.max(0,Math.min(100,it.pct));+ return `<div class="xl${ring?'':' nr'}" data-i="${i}">`+(ring?`<div class="cd" title="Time left until this limit resets"><svg viewBox="0 0 54 54"><circle class="trk" cx="27" cy="27" r="23"/><circle class="arc" cx="27" cy="27" r="23" stroke-dasharray="0 ${R_C.toFixed(1)}"/></svg><div class="ct"><b></b><small></small></div></div>`:'')++ `<div><div class="xh"><span class="xn">${esc(it.name||it.label)}</span><span class="xp ${c}">${Math.round(it.pct)}%<small>used</small></span></div>`++ `<div class="ub ${c}"><i style="width:${p}%"></i>${ring?'<em title="Even pace: where usage would be if spread evenly over the window"></em>':''}</div>`++ `<div class="xs"><span class="rs"></span><span class="pc"></span></div></div></div>`;}+let xKey='',xData=null,xTimer=null,xKick=0,lastExpand='';+function xShowing(){return window.innerHeight>=100;}+function renderX(d0){const x=document.getElementById('x');if(!x)return;+ const d=d0&&d0.stale?d0.stale:d0;xData=d0;const items=(d&&Array.isArray(d.items))?d.items:[];+ /* Nothing to say (a widget on this helper that draws no items, and no error): no bigger view,+ the card keeps its plain brief. */+ if(!items.length&&!(d0&&d0.error)){x.innerHTML='';withdrawExpand();return;}+ /* No title here: Hydrogen's card already shows the widget's name and mark right above this view.+ One quiet line instead: how fresh the numbers are, and the plan when the provider says. */+ const plan=d&&d.plan?esc(String(d.plan).charAt(0).toUpperCase()+String(d.plan).slice(1))+' plan':'';+ let h=`<div class="xhd"><span class="ag"></span>${plan?`<span class="pl">${plan}</span>`:''}</div>`;+ if(d0&&d0.error){const msg=d0.error==='starting'?'Checking your limits…':d0.error;+ h+=`<div class="xe"><span>${esc(msg.charAt(0).toUpperCase()+msg.slice(1))}${items.length&&d0.at?'. Showing the numbers from '+new Date(d0.at*1000).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'})+'.':''}</span>${d0.error==='starting'?'':'<button class="xr">Check now</button>'}</div>`;}+ h+=items.map(limRow).join('');+ const foot=[];+ if(d&&d.credits)foot.push(`<span class="cr">Usage credits: ${money(d.credits.used,d.credits.currency)} of ${money(d.credits.limit,d.credits.currency)} used</span>`);+ if(items.some(it=>it.resets_at&&it.window_s))foot.push('<span>Ring: time left until the limit resets. Tick: an even pace through the window.</span>');+ if(items.length)foot.push(`<span>Click the widget to change its look${viewName?' (now: '+esc(viewName)+')':''}.</span>`);+ if(foot.length)h+=`<div class="xf">${foot.join('')}</div>`;+ x.classList.toggle('stale',!!(d0&&d0.stale));x.innerHTML=h;+ const b=x.querySelector('.xr');if(b)b.addEventListener('click',e=>{e.stopPropagation();b.textContent='Checking…';refreshNow(false);});+ tickX();postExpand();armX();}+/* The live parts: ring arcs, countdowns, reset times, pace sentences, "updated n min ago". */+function tickX(){const x=document.getElementById('x');if(!x||!xData)return;const d=xData.stale||xData;const items=(d&&d.items)||[];const now=Date.now()/1000;+ const ag=x.querySelector('.ag');if(ag)ag.textContent=ago(xData.at);+ let due=false;+ x.querySelectorAll('.xl').forEach(row=>{const it=items[+row.dataset.i];if(!it)return;const at=it.resets_at,W=it.window_s;+ const rs=row.querySelector('.rs'),pc=row.querySelector('.pc');+ if(at&&W){const left=at-now;if(left<=0)due=true;const frac=Math.max(0,Math.min(1,left/W));+ const arc=row.querySelector('.arc');if(arc)arc.setAttribute('stroke-dasharray',(frac*R_C).toFixed(2)+' '+R_C.toFixed(1));+ const cd=row.querySelector('.cd');if(cd)cd.classList.toggle('soon',frac<0.1);+ const [big,small]=ringText(left);const ct=row.querySelector('.ct');if(ct){ct.querySelector('b').textContent=big;ct.querySelector('small').textContent=small;}+ const em=row.querySelector('.ub em');if(em)em.style.left=((1-frac)*100).toFixed(2)+'%';+ if(rs)rs.textContent=left>0?'Resets in '+span(left)+' · '+clock(at):'Resetting now';}+ else if(rs)rs.textContent=at?'Resets '+clock(at):'';+ const pt=paceText(it,now);if(pc){pc.textContent=pt?pt.t:'';pc.className='pc'+(pt&&pt.c?' '+pt.c:'');}});+ /* A window just rolled over: ask once for the fresh numbers instead of showing 80% on a new window. */+ if(due&&Date.now()-xKick>60000){xKick=Date.now();setTimeout(()=>refreshNow(false),4000);}}+function armX(){if(xShowing()&&!document.hidden){if(!xTimer)xTimer=setInterval(tickX,1000);}else if(xTimer){clearInterval(xTimer);xTimer=null;}}+window.addEventListener('resize',armX);document.addEventListener('visibilitychange',armX);+/* Ask Hydrogen for the bigger surface, sized to this view's content (measured on a hidden copy at+ the expanded width, like postWidth). Sent only when it changes; Hydrogen drops the request on+ every frame load, and every load renders, so it is re-sent then. */+function postExpand(){try{const x=document.getElementById('x');if(!x||window.parent===window)return;+ const c=x.cloneNode(true);c.removeAttribute('id');c.style.cssText='display:block;position:absolute;left:-10000px;top:0;width:'+XW+'px;max-height:none;height:auto;visibility:hidden;pointer-events:none';+ document.body.appendChild(c);const h=Math.ceil(c.getBoundingClientRect().height)+2;c.remove();+ const k=XW+'x'+h;if(k===lastExpand)return;lastExpand=k;window.parent.postMessage({hdWidget:{expand:{width:XW,height:h}}},'*');}catch(e){}}+function withdrawExpand(){if(lastExpand==='none'||window.parent===window)return;lastExpand='none';try{window.parent.postMessage({hdWidget:{expand:null}},'*');}catch(e){}}+window.addEventListener('load',()=>{if(lastExpand!=='none'){lastExpand='';postExpand();}});+/* Keys stay inside an iframe: hand Escape to Hydrogen so it closes the card like everywhere else. */+document.addEventListener('keydown',e=>{if(e.key==='Escape'&&window.parent!==window)window.parent.postMessage({hdWidget:{collapse:true}},'*');});+"""+ def _state_path(title): d = os.path.expanduser('~/.adom/widgets'); os.makedirs(d, exist_ok=True) return os.path.join(d, ''.join(ch if ch.isalnum() else '-' for ch in title.lower()) + '.state.json')@@ -196,7 +341,7 @@ def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, de # user clicks to another one, which is remembered on disk and wins from then on. cache = {'t': 0, 'v': {'error': 'starting'}, 'wait': refresh_ms/1000, 'good': None, 'fails': 0, 'view': str(st.get('view') or default_view)} if isinstance(st.get('good'), dict) and st['good'].get('items') is not None:- cache['good'] = st['good']+ cache['good'] = st['good']; cache['good_at'] = st.get('at') when = time.strftime('%H:%M', time.localtime(st.get('at') or 0)) if st.get('at') else 'earlier' cache['v'] = {'error': 'waiting for %s, last numbers from %s' % (provider, when), 'stale': cache['good']} lock = threading.Lock()@@ -213,8 +358,8 @@ def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, de try: cache['v'] = data_fn(); cache['wait'] = refresh_ms/1000 if not cache['v'].get('error'):- cache['good'] = cache['v']; cache['fails'] = 0- _save_state(title, {'good': cache['good'], 'at': time.time(), 'view': cache['view']})+ cache['good'] = cache['v']; cache['fails'] = 0; cache['good_at'] = time.time()+ _save_state(title, {'good': cache['good'], 'at': cache['good_at'], 'view': cache['view']}) except Exception as e: # A 401/403 is almost always an EXPIRED access token, not a missing sign-in: # Claude Code and Codex refresh their own tokens the next time they run, and@@ -242,6 +387,7 @@ def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, de cache['v'] = {'error': msg, 'stale': cache['good']} if cache['good'] else {'error': msg} cache['t'] = time.time() out = dict(cache['v']); out['view'] = cache['view']+ if cache.get('good_at'): out['at'] = int(cache['good_at']) if out.get('error'): out['retry_in'] = max(0, int(cache['wait'] - (time.time() - cache['t']))) return out def refresh_now():@@ -274,20 +420,27 @@ def serve(title, data_fn, render_js, refresh_ms=60000, mark=None, align_ms=0, de # from the corner, so a browser screenshot of the frame (at any device scale) is a # clean, crisp picture for docs. Without w the frame follows the width the widget asks # for, exactly as the Title Bar slot does; with w it is pinned. The slot never uses this.+ # /preview?x=1: the hover view instead, in a frame that follows the size the page asks+ # Hydrogen for ({hdWidget:{expand}}), on the card's ground, so it can be pictured too. from urllib.parse import urlparse, parse_qs q = parse_qs(urlparse(self.path).query) fixed = 'w' in q- try: w = int(q.get('w', ['175'])[0]); h = int(q.get('h', ['38'])[0])+ expand = q.get('x', ['0'])[0] not in ('0', '')+ try: w = int(q.get('w', ['380' if expand else '175'])[0]); h = int(q.get('h', ['300' if expand else '38'])[0]) except ValueError: w, h = 175, 38- w = max(48, min(640, w)); h = max(16, min(120, h))+ w = max(48, min(720, w)); h = max(16, min(600, h))+ ground = '#161b22' if expand else '#1c2128' page = ('<!doctype html><meta charset="utf-8"><title>%s preview</title>'- '<body style="margin:0;background:#0f1419"><div style="padding:150px 0 40px 40px">'- '<iframe id="w" src="/" data-w="0" style="width:%dpx;height:%dpx;border:1px solid #2c323a;border-radius:6px;background:#1c2128;display:block"></iframe>'- '</div><script>const fixed=%s;window.addEventListener("message",e=>{const m=e.data&&e.data.hdWidget;if(!m||!m.width)return;'- 'const f=document.getElementById("w");f.dataset.w=m.width;if(!fixed)f.style.width=Math.max(48,Math.min(640,m.width))+"px";});</script></body>') % (title, w, h, 'true' if fixed else 'false')+ '<body style="margin:0;background:#0f1419"><div style="padding:%s">'+ '<iframe id="w" src="/" data-w="0" style="width:%dpx;height:%dpx;border:1px solid #2c323a;border-radius:6px;background:%s;display:block"></iframe>'+ '</div><script>const fixed=%s,expand=%s;window.addEventListener("message",e=>{const m=e.data&&e.data.hdWidget;if(!m)return;'+ 'const f=document.getElementById("w");'+ 'if(expand){if(m.expand&&m.expand.width){f.style.width=m.expand.width+"px";f.style.height=Math.max(120,Math.min(600,m.expand.height))+"px";}return;}'+ 'if(!m.width)return;f.dataset.w=m.width;if(!fixed)f.style.width=Math.max(48,Math.min(640,m.width))+"px";});</script></body>') % (+ title, '40px' if expand else '150px 0 40px 40px', w, h, ground, 'true' if fixed else 'false', 'true' if expand else 'false') self._send(200, 'text/html; charset=utf-8', page.encode()) elif self.path.startswith('/health'): self._send(200, 'application/json', b'{"ok":true}')- else: self._send(200, 'text/html; charset=utf-8', (PAGE % {'title': title, 'default_view': default_view, 'refresh_ms': refresh_ms, 'align_ms': int(align_ms), 'render_js': render_js, 'mark_html': ('<span class="mark" style="color:%s"><svg viewBox="0 0 16 16">%s</svg></span>' % (mark.get('color', 'currentColor'), mark.get('svg', ''))) if mark.get('svg') else ''}).encode())+ else: self._send(200, 'text/html; charset=utf-8', (PAGE % {'title': title, 'default_view': default_view, 'refresh_ms': refresh_ms, 'align_ms': int(align_ms), 'render_js': render_js, 'x_css': X_CSS, 'x_js': X_JS, 'mark_html': ('<span class="mark" style="color:%s"><svg viewBox="0 0 16 16">%s</svg></span>' % (mark.get('color', 'currentColor'), mark.get('svg', ''))) if mark.get('svg') else ''}).encode()) # Bind with retries: a previous instance Hydrogen just stopped may still be releasing the # port for a moment. Ten tries over five seconds, then give up loudly (the log says why). ThreadingHTTPServer.allow_reuse_address = True
hydrogen-widget.json+4−4@@ -5,7 +5,7 @@ "id": "claude-usage", "name": "Claude usage", "icon": "vendor:claude",- "brief": "Your Claude limits at a glance: 5-hour session, weekly, and the weekly model bucket that applies, as percent used. Reads the Claude Code sign-in in this workspace.",+ "brief": "Your Claude limits at a glance: how much of the 5-hour session, the week, and your plan's weekly model limit you have used. Hover it for when each one resets and whether your pace runs out first.", "width": 320, "install": "adom/adom-vscode", "launch": {@@ -18,14 +18,14 @@ }, "docs": "widgets/README.md", "width_tall": 175,- "about": "Reads the Claude Code sign-in in this workspace (~/.claude/.credentials.json) and asks Claude's usage endpoint, the same one Claude Code's /usage panel reads. Draws every limit in the answer: the session window, the weekly window, and each weekly limit scoped to a model (Fable, Opus, ...), in the provider's severity colour. No model runs, so no quota is spent.",+ "about": "Reads the Claude Code sign-in in this workspace (~/.claude/.credentials.json) and asks Claude's usage endpoint, the same one Claude Code's /usage panel reads. Draws every limit in the answer: the session window, the weekly window, and each weekly limit scoped to a model (Fable, Opus, ...), in the provider's severity colour. Hovering it opens a bigger view in the card: a ring per limit that drains until the reset, the reset time on your clock, an even-pace tick on each bar, and one sentence on whether the current pace hits the limit first. No model runs, so no quota is spent.", "refresh": "the provider at most once every 3 minutes; pauses while the window is hidden; backs off (doubling to 15 minutes) on errors; re-reads the sign-in file only when it changes" }, { "id": "codex-usage", "name": "Codex usage", "icon": "vendor:codex",- "brief": "Your Codex limit as percent used (and the secondary window when your plan has one). Reads the Codex sign-in in this workspace.",+ "brief": "Your Codex limit at a glance: how much of the window you have used. Hover it for when it resets and whether your pace runs out first.", "width": 170, "install": "adom/adom-vscode", "launch": {@@ -38,7 +38,7 @@ }, "docs": "widgets/README.md", "width_tall": 135,- "about": "Reads the Codex sign-in in this workspace (~/.codex/auth.json) and asks Codex's usage endpoint, what Codex's /status shows: the plan's rate-limit window as percent used, plus a secondary window when the plan has one. No model runs, so no quota is spent.",+ "about": "Reads the Codex sign-in in this workspace (~/.codex/auth.json) and asks Codex's usage endpoint, what Codex's /status shows: the plan's rate-limit window as percent used, plus a secondary window when the plan has one. Hovering it opens the same bigger view as Claude usage: a ring that drains until the reset, the reset time, and a pace sentence. No model runs, so no quota is spent.", "refresh": "the provider at most once every 3 minutes; pauses while the window is hidden; backs off (doubling to 15 minutes) on errors; re-reads the sign-in file only when it changes" } ]
widgets/README.md+22−3@@ -20,9 +20,28 @@ Where the numbers come from: the Claude Code sign-in in your workspace (`~/.clau Your plan's rate-limit window as percent used, plus the secondary window when your plan has one. From the Codex sign-in in your workspace (`~/.codex/auth.json`), asked at `chatgpt.com/backend-api/wham/usage`, what Codex's `/status` reads. With no Codex sign-in the slot says so. +## Hover for when your limits reset++Rest the pointer on either widget and its hover card opens a bigger, live view of the same numbers (Hydrogen 1.0.429 and later; older builds keep the plain card):++++++For each limit:++- **The ring** is the time left until that limit resets. It drains as the window runs out and counts down on your own clock, to the second in the last hour, without asking the provider again.+- **The bar** is how much you have used. The small **tick** on it is where an even pace through the window would be: a bar past its tick is burning faster than the window.+- **Resets in 30 min · 1:10 PM**: the countdown and the reset time, in your time zone.+- **One sentence on your pace**: "On pace for about 90% by the reset", "Plenty of room", or, in amber, "At this pace you hit the limit around 3:01 PM, 6 days 17 h before it resets". A limit already used up says when you are back. The pace is the use so far divided by the share of the window gone by; in the first half hour of a window it says only that the window just started.++Below the limits: usage credits when your Claude account has them turned on, how fresh the numbers are, and the view the slot is showing. When the provider is not answering, the card says so in plain words over the last numbers, dimmed, with a **Check now** button. When a window rolls over, the view asks once for the fresh numbers instead of showing the old window's percent.++How it works: the widget page posts `{hdWidget: {expand: {width: 380, height}}}` with the height of its content, and Hydrogen moves the same frame into the card. The page switches layout on its own height (100 px and up is this view), so nothing reloads and nothing else in the slot changes. `/preview?x=1` shows the view on its own, which is how the pictures above were made.+ ## Views: click the widget to change what it shows -The rows are the default, and they are small. Click the slot and it moves to the next view; the widget asks the Title Bar for exactly the width that view needs, so a single number takes about a third of the space the rows did and its digits fill the slot's height. The choice is remembered per widget, across restarts. Hover the slot for the name of the current view and how many there are.+The rows are the default, and they are small. Click the slot and it moves to the next view; the widget asks the Title Bar for exactly the width that view needs, so a single number takes about a third of the space the rows did and its digits fill the slot's height. The choice is remembered per widget, across restarts. The hover view names the current one at its foot.  @@ -43,7 +62,7 @@ Codex usage has the same set for its two windows (eight views). In the compact T ## After a restart, and when the provider does not answer -The last good numbers are kept on disk, so after a reboot the widget shows them at once, dimmed, with "waiting for Anthropic, last numbers from 08:41" in its tooltip, and replaces them the moment the provider answers. A provider error is said in plain words ("no answer from OpenAI (HTTP 404), retrying"), the numbers stay on screen dimmed, and the next try comes at 5, 15, 45 and 90 s, then minutes, capped at fifteen, back to normal on the first success. An expired token is different: the widget waits for Claude Code or Codex to refresh it and re-reads the sign-in file when it changes.+The last good numbers are kept on disk, so after a reboot the widget shows them at once, dimmed, with "waiting for Anthropic, last numbers from 08:41" in its hover view, and replaces them the moment the provider answers. A provider error is said in plain words ("no answer from OpenAI (HTTP 404), retrying"), the numbers stay on screen dimmed, and the next try comes at 5, 15, 45 and 90 s, then minutes, capped at fifteen, back to normal on the first success. An expired token is different: the widget waits for Claude Code or Codex to refresh it and re-reads the sign-in file when it changes. ## How often they ask @@ -75,7 +94,7 @@ The package's `install.sh` symlinks `bin/hydrogen-widget-*` into `~/.local/bin`, ## Pictures of a widget -Every widget built on the helper serves `/preview?h=38` (full Title Bar) and `/preview?h=20` (compact): its own page in a frame of that height, which follows the width the widget asks for, exactly as the slot does. A browser screenshot of the frame at a high device scale is a crisp picture for a page like this one; the pictures above were made that way with headless Chrome at three times scale. Pin the width with `?w=` when you want a fixed frame.+Every widget built on the helper serves `/preview?h=38` (full Title Bar), `/preview?h=20` (compact) and `/preview?x=1` (the hover view): its own page in a frame of that height, which follows the width the widget asks for, exactly as the slot does. A browser screenshot of the frame at a high device scale is a crisp picture for a page like this one; the pictures above were made that way with headless Chrome at three times scale. Pin the width with `?w=` when you want a fixed frame. ## Make your own
widgets/docs/claude-usage-hover.pngadded⋯ 1 unchanged line ⋯
widgets/docs/codex-usage-hover.pngadded⋯ 1 unchanged line ⋯
Comments
Log in to comment.