From c2a34d914334d62f97c1fbc2a80a0a6dd949759f Mon Sep 17 00:00:00 2001 From: Shayan Date: Wed, 23 Sep 2026 16:13:52 -0400 Subject: [PATCH] feat: show usage pace icons on weekly and monthly bars Each weekly and monthly bar compares the share of its quota used with the share of its window gone. An icon beside the percentage shows the result: | on pace, > to >>> too fast (right of the value), < to <<< too slow (left of it), at 5, 15 and 30 points from pace. The value keeps its position. No icon on windows shorter than 2 days, in the first 10% or 12 hours of a window, or on exhausted limits. Monthly windows without a duration start one calendar month before the reset. usage.pace = false hides the icons. --- CHANGELOG.md | 5 + README.md | 20 +++- config.example.toml | 3 + devdash.py | 83 +++++++++++++-- docs/screenshot.svg | 241 ++++++++++++++++++++++-------------------- scripts/screenshot.py | 2 + tests/test_devdash.py | 65 ++++++++++++ 7 files changed, 297 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4699346..d128130 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +### Added + +- Pace icons on weekly and monthly usage bars: `>` to `>>>` when you use a quota faster than + time passes, `<` to `<<<` when slower, `|` on pace. Turn them off with `usage.pace = false`. + ## [0.1.0] - 2026-09-23 ### Added diff --git a/README.md b/README.md index d809e8f..71c571c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ named `devdash`. - **Review requests.** PRs that wait for you, newest first, with the author and age, and a marker when someone requests your review again. - **OMP AI usage (optional).** Rate-limit bars and reset countdowns for every provider that - the [OMP](https://omp.sh) coding agent tracks. Hidden when `omp` is not installed. + the [OMP](https://omp.sh) coding agent tracks. Hidden when `omp` is not installed. Weekly and + monthly bars show a pace icon (`>>` too fast, `<<` too slow, `|` on pace). - **Hide what you do not want to see.** Exclude single repositories or whole organizations. PR numbers are clickable in terminals that support hyperlinks. @@ -90,6 +91,22 @@ devdash --help # all flags |`⚠conflict` `↓behind`|Merge conflict, or the branch is behind its base| |`⇢queued #2`|Position in the merge queue| +### Usage pace + +Weekly and monthly bars have a pace icon beside the percentage: fast and on-pace icons on its +right, slow icons on its left. It compares the share of +the quota you used with the share of the window that has passed. For example, 34% used with +19 of 30 days gone (63%) is 29 points slow: `<<`. + +|Icon|Points from pace|Meaning| +|---|---|---| +|`\|`|less than 5|On pace| +|`>` `>>` `>>>`|5, 15, 30 or more ahead|Too fast; `>>>` runs out well before the reset| +|`<` `<<` `<<<`|5, 15, 30 or more behind|Too slow; you have quota to spare| + +There is no icon on 5-hour windows, or in the first 10% (at least 12 hours) of a window, +where one busy hour looks like a runaway pace. Set `usage.pace = false` to hide the icons. + ## Configuration Every setting is optional. devdash reads a [TOML](https://toml.io) file from the first of: @@ -108,6 +125,7 @@ review_requested = true # show the REVIEW REQUESTED section [usage] enabled = "auto" # true, false, or "auto" (only when omp is installed) refetch_after = 180 # force a fresh usage read after this many seconds; 0 = never +pace = true # pace icons (> fast, < slow, | on pace) on weekly and monthly bars order = ["openai-codex", "anthropic"] [usage.names] diff --git a/config.example.toml b/config.example.toml index cc81639..1041ef7 100644 --- a/config.example.toml +++ b/config.example.toml @@ -21,6 +21,9 @@ enabled = "auto" # true | false | "auto" # Force a fresh usage read when the cached one is older than this many seconds. 0 = never force. refetch_after = 180 +# Show a pace icon on weekly and monthly bars: > >> >>> too fast, < << <<< too slow, | on pace. +pace = true + # Providers listed here come first, in this order; the rest follow in the built-in order. order = [] # e.g. ["openai-codex", "anthropic"] diff --git a/devdash.py b/devdash.py index 24f0cb2..18269b6 100755 --- a/devdash.py +++ b/devdash.py @@ -17,6 +17,7 @@ """ import argparse +import calendar import json import os import re @@ -29,7 +30,7 @@ import tomllib import tty from concurrent.futures import ThreadPoolExecutor -from datetime import datetime +from datetime import UTC, datetime from rich.console import Console, Group from rich.live import Live @@ -93,7 +94,7 @@ def line(*parts): DEFAULTS = { "interval": 60, "github": {"exclude": [], "review_requested": True}, - "usage": {"enabled": "auto", "refetch_after": 180, "order": [], "names": {}, "colors": {}}, + "usage": {"enabled": "auto", "refetch_after": 180, "pace": True, "order": [], "names": {}, "colors": {}}, } REPO_RULE = re.compile(r"^[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)?$") @@ -241,23 +242,84 @@ def heat(frac): return "#%02x%02x%02x" % HEAT[-1][1] -def meter(frac, width, label, exhausted=False): +def meter(frac, width, label, exhausted=False, icon=None): """A bar with its value printed inside, like omp's context meter. Each filled cell takes the heat colour of its own position, so a fuller bar - runs from green through amber to red.""" + runs from green through amber to red. `icon` (a pace icon) sits one space + right of the value, or left for a slow '<' icon; the value never moves.""" frac = 1.0 if exhausted else max(0.0, min(1.0, frac)) - text = label.center(width) + left = (width - len(label)) // 2 + cells = [" "] * width + cells[left:left + len(label)] = label + icon_at = range(0) + if icon is not None: + at = left - 1 - len(icon) if icon.plain.startswith("<") else left + len(label) + 1 + if at >= 0 and at + len(icon) <= width: + cells[at:at + len(icon)] = icon.plain + icon_at = range(at, at + len(icon)) full = round(frac * width) tip = heat(frac) t = Text(no_wrap=True) - for i, ch in enumerate(text): + for i, ch in enumerate(cells[:width]): if i < full: t.append(ch, style=f"bold {INK} on {heat(i / max(1, width - 1))}") + elif i in icon_at: + t.append(ch, style=f"{icon.style} on {TRACK}") else: t.append(ch, style=f"bold {tip} on {TRACK}") return t +DAY = 86400 +WINDOW_SECONDS = {"5h": 5 * 3600, "daily": DAY, "7d": 7 * DAY, "weekly": 7 * DAY} + + +def window_start(win): + """Epoch seconds when a limit's window began, or None. omp gives no + duration for calendar-month windows, so those start one month before reset.""" + reset = win.get("resetsAt") + if not reset: + return None + reset /= 1000 + if win.get("durationMs"): + return reset - win["durationMs"] / 1000 + if win.get("id") == "monthly": + r = datetime.fromtimestamp(reset, UTC) + y, m = (r.year, r.month - 1) if r.month > 1 else (r.year - 1, 12) + return r.replace(year=y, month=m, day=min(r.day, calendar.monthrange(y, m)[1])).timestamp() + span = WINDOW_SECONDS.get(win.get("id")) + return reset - span if span else None + + +def pace(lim, now): + """Points of quota used ahead of (+) or behind (-) the share of time gone, + for multi-day windows, else None. Hidden early in a window, where 1% after + an hour would read as a wild pace.""" + win = lim.get("window") or {} + used = (lim.get("amount") or {}).get("usedFraction") + start = window_start(win) + if used is None or start is None or lim.get("status") == "exhausted": + return None + span, gone = win["resetsAt"] / 1000 - start, now - start + if span < 2 * DAY or gone < max(DAY / 2, span / 10) or gone >= span: + return None + return round((used - gone / span) * 100) + + +# Pace icon by distance from on-pace, in points: > fast, < slow, | on pace. +PACE_STEPS = [(30, 3), (15, 2), (5, 1)] +FAST_STYLE = {1: "yellow3", 2: "dark_orange", 3: "bold red1"} +SLOW_STYLE = {1: "#79c0ff", 2: "#79c0ff", 3: "bold #79c0ff"} + + +def pace_icon(points): + """'|' within 5 points of pace, then one to three '>' (too fast) or '<' (too slow).""" + n = next((k for step, k in PACE_STEPS if abs(points) >= step), 0) + if not n: + return Text("|", style=OK) + return Text((">" if points > 0 else "<") * n, style=(FAST_STYLE if points > 0 else SLOW_STYLE)[n]) + + def usage_rows(report): """Visible limits: shared quotas appear once, uncapped zero counters are hidden.""" rows, seen = [], set() @@ -316,8 +378,11 @@ def render_usage(st, width, now): val = f"${amt['used']:.0f}/${amt['limit']:.0f}" else: val = f"{frac * 100:.0f}%" - value = meter(frac, bar_w, val, lim.get("status") == "exhausted") - if stale and reset and reset / 1000 < now: # the old number no longer applies + expired = stale and reset and reset / 1000 < now # the old number no longer applies + delta = pace(lim, now) if st.show_pace and not expired else None + icon = pace_icon(delta) if delta is not None else None + value = meter(frac, bar_w, val, lim.get("status") == "exhausted", icon) + if expired: value = Text("reset since last read".center(bar_w)[:bar_w], style=f"{LABEL} on {TRACK}") out.append(line((label + " ", LABEL), value, " ", rst)) missing = sorted({PROVIDER_NAME.get(a["provider"], a["provider"]) for a in data.get("accountsWithoutUsage", [])}) @@ -586,6 +651,7 @@ class State: interval = 60 show_usage = True show_review = True + show_pace = True excluded = [] @@ -679,6 +745,7 @@ def main(): PROVIDER_ORDER[:] = list(dict.fromkeys(usage["order"] + PROVIDER_ORDER)) PROVIDER_NAME.update(usage["names"]) BRAND.update(usage["colors"]) + st.show_pace = usage["pace"] usage_every = args.usage_every if args.usage_every is not None else usage["refetch_after"] if args.cached or usage_every == 0: usage_every = None diff --git a/docs/screenshot.svg b/docs/screenshot.svg index bf89692..a040d10 100644 --- a/docs/screenshot.svg +++ b/docs/screenshot.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + - Dev-Dashboard + Dev-Dashboard - - - - USAGE  fetched 12s ago -● Anthropic -5h 42% 2h24m -7d 71% 2d23h - -● Codex -5h 12% 3h59m -7d 93%   39m - -────────────────────────────────────────────────────── -MY PRS 4  fetched 4s ago - -api  acme -┃ stack #12 → main -┃ #207 feat(api): expose limits in headers -┃ ●ci1needs review✎sam⚑2 -┃ #204 feat(api): add rate limits -┃ ✓ciapproved✓danabot✎1 -┃ #201 ✓merged refactor(api): split the router - -web  acme -#88 fix(web): keep the sidebar open after a reload -✗ci1changes✗lee⚑1test (3.13) - -#91 docs: explain the deploy flow -✓cidraftneeds review↓behind - -────────────────────────────────────────────────────── -REVIEW REQUESTED 2 - -#312 feat(cli): add --json output -cli@dana2h00m -✓cineeds review✓sam - -#95 chore(deps): bump rich to 14 -web@lee1d02h  re-requested -●ci1needs review - -15:25:47 · every 60s · r refresh · q quit + + + + USAGE  fetched 12s ago +● Anthropic +5h        42% 2h24m +7d        71%> 2d23h + +● Codex +5h        12% 3h59m +7d        <93%   39m + +● Cursor +cursor mo <<34%10d23h + +────────────────────────────────────────────────────── +MY PRS 4  fetched 4s ago + +api  acme +┃ stack #12 → main +┃ #207 feat(api): expose limits in headers +┃ ●ci1needs review✎sam⚑2 +┃ #204 feat(api): add rate limits +┃ ✓ciapproved✓danabot✎1 +┃ #201 ✓merged refactor(api): split the router + +web  acme +#88 fix(web): keep the sidebar open after a reload +✗ci1changes✗lee⚑1test (3.13) + +#91 docs: explain the deploy flow +✓cidraftneeds review↓behind + +────────────────────────────────────────────────────── +REVIEW REQUESTED 2 + +#312 feat(cli): add --json output +cli@dana2h00m +✓cineeds review✓sam + +#95 chore(deps): bump rich to 14 +web@lee1d02h  re-requested +●ci1needs review + +16:05:46 · every 60s · r refresh · q quit diff --git a/scripts/screenshot.py b/scripts/screenshot.py index aafd222..4111005 100644 --- a/scripts/screenshot.py +++ b/scripts/screenshot.py @@ -24,6 +24,8 @@ def limit(label, window, frac, reset_in): limit("Claude 5 Hour", "5h", 0.42, 2 * HOUR + 1500), limit("Claude 7 Day", "7d", 0.71, 3 * 24 * HOUR)]}, {"provider": "openai-codex", "fetchedAt": NOW * 1000, "limits": [ limit("5 Hour", "5h", 0.12, 4 * HOUR), limit("7 Day", "7d", 0.93, 40 * 60)]}, + {"provider": "cursor", "fetchedAt": NOW * 1000, "limits": [ + limit("Cursor Models", "monthly", 0.34, 11 * 24 * HOUR)]}, ]} diff --git a/tests/test_devdash.py b/tests/test_devdash.py index e05df8b..9627ad9 100644 --- a/tests/test_devdash.py +++ b/tests/test_devdash.py @@ -117,3 +117,68 @@ def fake_run(cmd, timeout=45): monkeypatch.setattr(devdash, "run", fake_run) with pytest.raises(RuntimeError, match="401"): devdash.fetch_prs([], review_requested=True) + + +DAY = 86400 +NOW = 1_800_000_000 + + +def limit(window, used, left, duration=None): + win = {"id": window, "resetsAt": (NOW + left) * 1000} + if duration: + win["durationMs"] = duration * 1000 + return {"label": "x", "window": win, "amount": {"usedFraction": used}} + + +def test_monthly_window_starts_one_calendar_month_before_reset(): + from datetime import UTC, datetime + reset = datetime(2026, 3, 31, 12, tzinfo=UTC).timestamp() + start = devdash.window_start({"id": "monthly", "resetsAt": reset * 1000}) + assert datetime.fromtimestamp(start, UTC) == datetime(2026, 2, 28, 12, tzinfo=UTC) + + +def test_pace_is_points_used_ahead_of_time(): + # 20% used with 4 of 7 days gone: 37 points behind. 60% with 3 of 7 gone: 17 ahead. + assert devdash.pace(limit("7d", 0.20, 3 * DAY), NOW) == -37 + assert devdash.pace(limit("7d", 0.60, 4 * DAY, duration=7 * DAY), NOW) == 17 + + +@pytest.mark.parametrize(("points", "icon"), [ + (0, "|"), (4, "|"), (-4, "|"), + (5, ">"), (15, ">>"), (29, ">>"), (30, ">>>"), + (-5, "<"), (-15, "<<"), (-30, "<<<"), +]) +def test_pace_icon_steps(points, icon): + assert devdash.pace_icon(points).plain == icon + + +@pytest.mark.parametrize("lim", [ + limit("5h", 0.9, 3600), # not a multi-day window + limit("7d", 0.05, 7 * DAY - 3600), # an hour in: too early to judge + limit("mystery", 0.5, DAY), # no way to know when the window began + dict(limit("7d", 1.0, 3 * DAY), status="exhausted"), # nothing left to pace +]) +def test_pace_hidden(lim): + assert devdash.pace(lim, NOW) is None + + + +@pytest.mark.parametrize("show", [True, False]) +def test_usage_row_shows_pace_icon_unless_turned_off(show): + st = devdash.State() + st.show_pace = show + st.usage = {"reports": [{"provider": "cursor", "fetchedAt": NOW * 1000, + "limits": [dict(limit("monthly", 0.34, 11 * DAY), label="Cursor Models")]}]} + row = devdash.render_usage(st, 50, NOW)[-1].plain + assert ("<<" in row) is show + + +@pytest.mark.parametrize(("points", "row"), [ + (None, " 34% "), + (0, " 34% | "), + (20, " 34% >> "), + (-20, " << 34% "), +]) +def test_pace_icon_sits_beside_a_value_that_never_moves(points, row): + icon = devdash.pace_icon(points) if points is not None else None + assert devdash.meter(0.0, 20, "34%", icon=icon).plain == row