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