Initial commit: ocd waybar plugin
Daemon (scripts/ocd-waybar) polls {
"schema_version": 1,
"generated_at": "2026-07-30T01:29:37.918330160+00:00",
"accounts": [
{
"account": "legal",
"workspace": "wrk_01KBJFEAZV7G2FJBE1KAPRQSJN",
"fetched_at": "2026-07-30T01:27:49.014903087+00:00",
"status": "cached",
"limits": {
"usage_5h": {
"used": 0.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-07-30T06:27:49.014903087+00:00",
"status": "ok"
},
"usage_week": {
"used": 88.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-03T00:00:01.014903087+00:00",
"status": "ok"
},
"usage_month": {
"used": 94.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-21T22:07:23.014903087+00:00",
"status": "ok"
},
"next_update": null
},
"balance": {
"remaining": 0.0,
"unit": "$"
}
},
{
"account": "legal2",
"workspace": "wrk_01KQHV4ZP2SHZHTCTF0HW1BKHA",
"fetched_at": "2026-07-30T01:27:49.920803926+00:00",
"status": "cached",
"limits": {
"usage_5h": {
"used": 0.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-07-30T06:27:49.920803926+00:00",
"status": "ok"
},
"usage_week": {
"used": 100.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-03T00:00:00.920803926+00:00",
"status": "rate-limited"
},
"usage_month": {
"used": 90.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-23T18:01:33.920803926+00:00",
"status": "ok"
},
"next_update": null
},
"balance": {
"remaining": 0.0,
"unit": "$"
}
},
{
"account": "kllr560",
"workspace": "wrk_01KYJ5B4PM2EGR16ZDC0JTH2CC",
"fetched_at": "2026-07-30T01:27:50.791975864+00:00",
"status": "cached",
"limits": {
"usage_5h": {
"used": 0.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-07-30T06:27:50.791975864+00:00",
"status": "ok"
},
"usage_week": {
"used": 79.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-03T00:00:00.791975864+00:00",
"status": "ok"
},
"usage_month": {
"used": 39.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-27T16:10:15.791975864+00:00",
"status": "ok"
},
"next_update": null
},
"balance": {
"remaining": 0.0,
"unit": "$"
}
},
{
"account": "pj-vendee",
"workspace": "wrk_01KYNGQCX4XEM0KD3PWVVE636D",
"fetched_at": "2026-07-30T01:27:51.638144968+00:00",
"status": "cached",
"limits": {
"usage_5h": {
"used": 0.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-07-30T06:27:51.638144968+00:00",
"status": "ok"
},
"usage_week": {
"used": 40.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-03T00:00:00.638144968+00:00",
"status": "ok"
},
"usage_month": {
"used": 20.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-29T01:09:27.638144968+00:00",
"status": "ok"
},
"next_update": null
},
"balance": {
"remaining": 0.0,
"unit": "$"
}
},
{
"account": "iyaunalunarovell",
"workspace": "wrk_01KYNTSGXF7GFXMAMT36N3GH21",
"fetched_at": "2026-07-30T01:27:52.584601387+00:00",
"status": "cached",
"limits": {
"usage_5h": {
"used": 94.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-07-30T02:27:25.584601387+00:00",
"status": "ok"
},
"usage_week": {
"used": 99.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-03T00:00:00.584601387+00:00",
"status": "ok"
},
"usage_month": {
"used": 49.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-29T02:22:06.584601387+00:00",
"status": "ok"
},
"next_update": null
},
"balance": {
"remaining": 0.0,
"unit": "$"
}
},
{
"account": "balensiefenpiwihu2906",
"workspace": "wrk_01KYQNFP718WP7X9W7RXC2Q133",
"fetched_at": "2026-07-30T01:27:53.457572405+00:00",
"status": "cached",
"limits": {
"usage_5h": {
"used": 53.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-07-30T03:43:22.457572405+00:00",
"status": "ok"
},
"usage_week": {
"used": 21.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-03T00:00:00.457572405+00:00",
"status": "ok"
},
"usage_month": {
"used": 10.0,
"total": 100.0,
"unit": "%",
"resets_at": "2026-08-29T19:27:31.457572405+00:00",
"status": "ok"
},
"next_update": null
},
"balance": {
"remaining": 0.0,
"unit": "$"
}
}
]
} every 30s (no force
flags, so ocd cache/TTL stays in charge), runs a 5s account rotation
in config order, and supports right-click 1h lock. The waybar
custom/ocd module reads a state.json written by the daemon; hover
shows all accounts via the native tooltip. Bars are Pango background
spans (~50px) in the label and plain block chars in the tooltip, since
waybar 0.15.0 renders custom tooltips as plain text.
This commit is contained in:
commit
ea834a99a5
137
README.md
Normal file
137
README.md
Normal file
@ -0,0 +1,137 @@
|
||||
# ocpway — waybar plugin for `ocd`
|
||||
|
||||
A waybar module that shows opencode usage limits and balance from the
|
||||
`ocd` dashboard binary. It rotates through the accounts in your ocd
|
||||
config, one every 5 seconds. Left-click advances at once. Right-click
|
||||
locks the current account for one hour. Hover the bar to see all
|
||||
accounts at once.
|
||||
|
||||
## What it shows
|
||||
|
||||
One line per account:
|
||||
|
||||
```
|
||||
kllr560 [0%/5h] ▓░░░░░░ 4:59 [79%/1w] █████▌░ 3d 22h [39%/1m] ██▋░░░░ 28d $0.00 / 1m ago
|
||||
```
|
||||
|
||||
- Account name, and a lock emoji `🔒` while locked.
|
||||
- Three bars: the 5-hour, weekly, and monthly usage window.
|
||||
- The label holds the used value with its unit, e.g. `[79%/1w]`.
|
||||
- The bar is the percentage, drawn as filled cells up to the value.
|
||||
- The text after the bar is the time until the window resets.
|
||||
- The balance and the age of the last data fetch:
|
||||
`$0.00 / 1m ago`.
|
||||
|
||||
When locked, the bar cells lock to blue and the line starts with `🔒`.
|
||||
|
||||
The hover tooltip lists every account the same way. The active row is
|
||||
marked with `▸`, and the lock emoji shows only on the locked row.
|
||||
|
||||
## Design decisions
|
||||
|
||||
The bar is rectangular. It uses Pango `background` spans over non-breaking
|
||||
space cells. Seven cells are about 50 px at a normal bar font. The bar in
|
||||
the tooltip uses plain block characters because waybar 0.15.0 renders the
|
||||
custom-module tooltip as plain text. If your waybar gains Pango markup in
|
||||
custom tooltips (see Waybar PR #5034), set `TOOLTIP_MARKUP = True` in the
|
||||
daemon to get colored background-span bars in the tooltip too.
|
||||
|
||||
The daemon polls `ocd raw json` every 30 seconds. It sends no
|
||||
`--force-refresh` or `--no-cache` flags, so `ocd` keeps full control of
|
||||
politeness: its own cache, TTL, concurrency, and pacing stay in charge.
|
||||
Most polls read the cache and never touch the network. The daemon also
|
||||
polls at once waybar starts
|
||||
|
||||
waybar 0.15.0 has no `on-hover` event for custom modules, so the
|
||||
all-accounts view is the native tooltip, not a separate floating window.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
ocpway/
|
||||
scripts/
|
||||
ocd-waybar the daemon (python)
|
||||
ocd-waybar-ctl the click helper (sh) for advance / lock / poll / quit
|
||||
config/
|
||||
waybar-config.jsonc the "custom/ocd" module block to add
|
||||
waybar-style.css the CSS to append
|
||||
hypr-autostart.conf the exec-once line to start the daemon
|
||||
README.md
|
||||
```
|
||||
|
||||
## Install
|
||||
|
||||
Copy the scripts and wiring them into your own config.
|
||||
|
||||
1. Copy the scripts:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/waybar/scripts
|
||||
cp scripts/ocd-waybar scripts/ocd-waybar-ctl ~/.config/waybar/scripts/
|
||||
chmod +x ~/.config/waybar/scripts/ocd-waybar{,-ctl}
|
||||
```
|
||||
|
||||
2. Add the `custom/ocd` module to `~/.config/waybar/config.jsonc`.
|
||||
See `config/waybar-config.jsonc` for the block and where the module
|
||||
name goes in `modules-right`.
|
||||
|
||||
3. Append the CSS in `config/waybar-style.css` to `~/.config/waybar/style.css`.
|
||||
|
||||
4. Add the `exec-once` line in `config/hypr-autostart.conf` to
|
||||
`~/.config/hypr/autostart.conf`.
|
||||
|
||||
5. Restart waybar, and start the daemon:
|
||||
|
||||
```sh
|
||||
pkill -x waybar; waybar &
|
||||
~/.config/waybar/scripts/ocd-waybar &
|
||||
```
|
||||
|
||||
On later logins, the `exec-once` line starts the daemon for you.
|
||||
|
||||
## Usage
|
||||
|
||||
- Hover the module: see all accounts.
|
||||
- Left-click: advance to the next account at once (resets the 5-second timer).
|
||||
- Right-click: lock the current account for one hour.
|
||||
|
||||
## `ocd-waybar-ctl` commands
|
||||
|
||||
| Command | Effect |
|
||||
|----------|--------|
|
||||
| advance | Reset the timer and move to the next account. |
|
||||
| lock | Freeze the current account for one hour. |
|
||||
| poll | Run an ocd poll now (still respects ocd cache and TTL). |
|
||||
| quit | Stop the daemon. |
|
||||
|
||||
The helper tries `socat`, then `nc -U -u`, then a small python fallback.
|
||||
|
||||
## Tunables
|
||||
|
||||
At the top of `scripts/ocd-waybar`:
|
||||
|
||||
| Name | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `ROTATE_SECS` | 5 | Auto-advance cadence. |
|
||||
| `LOCK_SECS` | 3600 | One hour lock duration. |
|
||||
| `POLL_SECS` | 30 | ocd re-poll cadence. |
|
||||
| `BAR_WIDTH` | 7 | Bar cells, about 50 px. |
|
||||
| `TOOLTIP_MARKUP` | False | Set True if your waybar renders Pango in custom tooltips. |
|
||||
| `C_OK` / `C_WARN` / `C_CRIT` / `C_LOCK` / `C_TRACK` | see source | Zone colors. |
|
||||
| `OCD_BIN` | `ocd` | Path to the ocd binary (or set the `OCD_BIN` env var). |
|
||||
|
||||
Zone thresholds: under 70 percent is green, 70 to 90 is amber, over 90 or a
|
||||
`rate-limited` frame status is red.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `ocd`, built and on `PATH` (or pointed at with `OCD_BIN`).
|
||||
- `python3` (the daemon). Standard library only.
|
||||
- `waybar`. Tested on 0.15.0.
|
||||
- For clicks: `socat` is preferred, `nc` is a fallback, else the python fallback runs.
|
||||
|
||||
## State
|
||||
|
||||
The daemon writes `~/.local/state/ocd-waybar/state.json`. The waybar module
|
||||
reads that file once per second with `cat` and emits it as a JSON object.
|
||||
The socket lives at `$XDG_RUNTIME_DIR/ocd-waybar.sock`.
|
||||
5
config/hypr-autostart.conf
Normal file
5
config/hypr-autostart.conf
Normal file
@ -0,0 +1,5 @@
|
||||
# Add to ~/.config/hypr/autostart.conf to start the daemon on login.
|
||||
# The omarchy default uses uwsm app -- for wayland-session-managed processes.
|
||||
|
||||
# ocd waybar daemon (polls ocd, drives the custom/ocd module)
|
||||
exec-once = uwsm app -- ~/.config/waybar/scripts/ocd-waybar
|
||||
27
config/waybar-config.jsonc
Normal file
27
config/waybar-config.jsonc
Normal file
@ -0,0 +1,27 @@
|
||||
// Snippet to add to ~/.config/waybar/config.jsonc
|
||||
//
|
||||
// 1. Add "custom/ocd" to the "modules-right" (or modules-left/center) array:
|
||||
//
|
||||
// "modules-right": [
|
||||
// "custom/lang",
|
||||
// "custom/ocd", // <- add this
|
||||
// "group/tray-expander",
|
||||
// "network",
|
||||
// "pulseaudio",
|
||||
// "cpu",
|
||||
// "memory",
|
||||
// "clock",
|
||||
// ],
|
||||
//
|
||||
// 2. Add the module block below anywhere in the top-level object:
|
||||
|
||||
"custom/ocd": {
|
||||
"exec": "cat $HOME/.local/state/ocd-waybar/state.json",
|
||||
"exec-if": "test -f $HOME/.local/state/ocd-waybar/state.json",
|
||||
"return-type": "json",
|
||||
"interval": 1,
|
||||
"format": "{}",
|
||||
"tooltip-format": "{}",
|
||||
"on-click": "~/.config/waybar/scripts/ocd-waybar-ctl advance",
|
||||
"on-click-right": "~/.config/waybar/scripts/ocd-waybar-ctl lock"
|
||||
},
|
||||
12
config/waybar-style.css
Normal file
12
config/waybar-style.css
Normal file
@ -0,0 +1,12 @@
|
||||
/* Append to ~/.config/waybar/style.css. */
|
||||
/* The monospace font makes the nbsp bar cells line up as rectangles. */
|
||||
/* The lock class tints the label blue while a 1-hour lock is active. */
|
||||
|
||||
#custom-ocd {
|
||||
font-family: monospace;
|
||||
padding: 0 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
#custom-ocd.ocd-locked {
|
||||
color: #5aa6e0;
|
||||
}
|
||||
418
scripts/ocd-waybar
Executable file
418
scripts/ocd-waybar
Executable file
@ -0,0 +1,418 @@
|
||||
#!/usr/bin/env python3
|
||||
# ocd-waybar: waybar daemon for the ocd opencode-limits dashboard.
|
||||
#
|
||||
# It polls `ocd raw json` every 30 s. It does not pass --force-refresh or
|
||||
# --no-cache, so ocd's own cache and TTL stay in charge of politeness.
|
||||
# It holds the 5 s rotation timer and the 1 h lock, renders one Pango line
|
||||
# for the displayed account and a multi-line Pango tooltip for all accounts,
|
||||
# and writes the result as JSON for the waybar custom/ocd module.
|
||||
#
|
||||
# Clicks arrive over a unix socket from ocd-waybar-ctl:
|
||||
# advance -> reset the 5 s timer and move to the next account
|
||||
# lock -> freeze the current account for one hour
|
||||
# poll -> force an ocd poll now (still respects ocd's cache/TTL)
|
||||
# quit -> stop the daemon
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
# --- tunables ---------------------------------------------------------------
|
||||
ROTATE_SECS = 5 # normal auto-advance cadence
|
||||
LOCK_SECS = 3600 # one hour lock
|
||||
POLL_SECS = 30 # ocd re-poll cadence (cache serves most calls)
|
||||
TICK_SECS = 0.5 # main loop resolution
|
||||
BAR_WIDTH = 7 # nbsp cells per bar (~50 px at normal bar font)
|
||||
STATE_DIR = Path.home() / ".local" / "state" / "ocd-waybar"
|
||||
STATE_FILE = STATE_DIR / "state.json"
|
||||
SOCK_FILE = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "ocd-waybar.sock"
|
||||
OCD_BIN = os.environ.get("OCD_BIN", "ocd")
|
||||
# Default False: waybar 0.15.0 renders the custom-module tooltip as plain
|
||||
# text, so tags would show literally. Set True if your waybar supports
|
||||
# Pango markup in custom tooltips (see Waybar PR #5034).
|
||||
TOOLTIP_MARKUP = False
|
||||
|
||||
# zone colors
|
||||
C_OK = "#43c267"
|
||||
C_WARN = "#e0a030"
|
||||
C_CRIT = "#e0533b"
|
||||
C_LOCK = "#5aa6e0"
|
||||
C_TRACK = "#39424d"
|
||||
|
||||
ZONE = {("ok",): 0, ("warn",): 1, ("crit",): 2}
|
||||
# Each label is a template: {} is filled with the used value plus its
|
||||
# unit, e.g. 88 and "%" -> "[88%/5h]". With no data the unit alone shows.
|
||||
FRAME_LABELS = (("usage_5h", "[{}/5h]"), ("usage_week", "[{}/1w]"), ("usage_month", "[{}/1m]"))
|
||||
|
||||
|
||||
def zone_color(pct, status=None, locked=False):
|
||||
if locked:
|
||||
return C_LOCK
|
||||
if status == "rate-limited" or pct >= 90:
|
||||
return C_CRIT
|
||||
if pct >= 70:
|
||||
return C_WARN
|
||||
return C_OK
|
||||
|
||||
|
||||
def zone_name(pct, status=None):
|
||||
if status == "rate-limited" or pct >= 90:
|
||||
return "ocd-crit"
|
||||
if pct >= 70:
|
||||
return "ocd-warn"
|
||||
return "ocd-ok"
|
||||
|
||||
|
||||
def parse_dt(s):
|
||||
"""Parse an ISO 8601 string with possible nanosecond precision."""
|
||||
if not s:
|
||||
return None
|
||||
s = s.strip()
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
# Python only handles up to 6 fractional digits. Trim the rest.
|
||||
m = re.search(r"(\.\d{6})\d+", s)
|
||||
if m:
|
||||
s = s[:m.start()] + m.group(1) + s[m.end():]
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def rel_reset(resets_at, now):
|
||||
"""Human string for time until reset, e.g. '4:39', '3d 1h', '20d'."""
|
||||
if not resets_at:
|
||||
return "\u2014"
|
||||
dt = parse_dt(resets_at)
|
||||
if dt is None:
|
||||
return "\u2014"
|
||||
sec = (dt - now).total_seconds()
|
||||
if sec <= 0:
|
||||
return "now"
|
||||
days = int(sec // 86400)
|
||||
rem = sec - days * 86400
|
||||
hours = int(rem // 3600)
|
||||
mins = int((rem % 3600) // 60)
|
||||
if days >= 7:
|
||||
return f"{days}d"
|
||||
if days >= 1:
|
||||
h = hours if hours else 0
|
||||
return f"{days}d {h}h"
|
||||
return f"{hours}:{mins:02d}"
|
||||
|
||||
|
||||
def rel_ago(fetched_at, now):
|
||||
if not fetched_at:
|
||||
return "\u2014"
|
||||
dt = parse_dt(fetched_at)
|
||||
if dt is None:
|
||||
return "\u2014"
|
||||
sec = (now - dt).total_seconds()
|
||||
if sec < 0:
|
||||
sec = 0
|
||||
if sec < 60:
|
||||
return f"{int(sec)}s ago"
|
||||
m = int(sec // 60)
|
||||
if m < 60:
|
||||
return f"{max(1, m)}m ago"
|
||||
h = int(sec // 3600)
|
||||
if h < 24:
|
||||
return f"{h}h ago"
|
||||
return f"{h // 24}d ago"
|
||||
|
||||
|
||||
def bar_html(pct, status=None, locked=False):
|
||||
pct = max(0.0, min(100.0, float(pct)))
|
||||
fill = int(round(pct / 100.0 * BAR_WIDTH))
|
||||
fill = max(0, min(BAR_WIDTH, fill))
|
||||
empty = BAR_WIDTH - fill
|
||||
fc = zone_color(pct, status, locked)
|
||||
out = f'<span background="{fc}">{"\u00a0" * fill}</span>'
|
||||
if empty:
|
||||
out += f'<span background="{C_TRACK}">{"\u00a0" * empty}</span>'
|
||||
return out
|
||||
|
||||
|
||||
def bar_plain(pct, status=None, locked=False):
|
||||
"""Plain block-character bar for tooltip text that may not parse markup."""
|
||||
pct = max(0.0, min(100.0, float(pct)))
|
||||
units = pct / 100.0 * BAR_WIDTH # cells, fractional
|
||||
full = int(units)
|
||||
frac = units - full
|
||||
blocks = "▏▎▍▌▋▊▉" # one-eighth steps
|
||||
out = "█" * full
|
||||
if full < BAR_WIDTH and frac > 0:
|
||||
idx = int(round(frac * 7)) - 1 # 7 fractional steps
|
||||
idx = max(0, min(6, idx))
|
||||
out += blocks[idx]
|
||||
full += 1
|
||||
out += "░" * (BAR_WIDTH - full) # light shade for the empty track
|
||||
if locked and full < BAR_WIDTH:
|
||||
out = out[:full] + "▓" + out[full + 1:] # dim mark on locked rows
|
||||
return out[:BAR_WIDTH]
|
||||
|
||||
|
||||
def frame_get(limits, key):
|
||||
if not limits:
|
||||
return None
|
||||
return limits.get(key)
|
||||
|
||||
|
||||
def pct_of(frame):
|
||||
if frame is None:
|
||||
return 0.0
|
||||
total = float(frame.get("total") or 0) or 100.0
|
||||
used = float(frame.get("used") or 0)
|
||||
return used / total * 100.0 if total else 0.0
|
||||
|
||||
|
||||
def worst_zone(account, locked=False):
|
||||
if not account or not account.get("limits"):
|
||||
return "ocd-ok"
|
||||
worst = 0
|
||||
names = ("usage_5h", "usage_week", "usage_month")
|
||||
for k in names:
|
||||
f = account["limits"].get(k)
|
||||
if not f:
|
||||
continue
|
||||
zn = zone_name(pct_of(f), f.get("status"))
|
||||
w = {"ocd-ok": 0, "ocd-warn": 1, "ocd-crit": 2}[zn]
|
||||
worst = max(worst, w)
|
||||
return ("ocd-ok", "ocd-warn", "ocd-crit")[worst]
|
||||
|
||||
|
||||
def balance_str(account):
|
||||
bal = account.get("balance") if account else None
|
||||
if not bal:
|
||||
return "\u2014"
|
||||
remaining = float(bal.get("remaining") or 0.0)
|
||||
unit = bal.get("unit") or ""
|
||||
# Currency symbol prefixes; others append a space.
|
||||
if unit and not unit.isalpha():
|
||||
return f"{unit}{remaining:.2f}"
|
||||
return f"{remaining:.2f} {unit}".strip()
|
||||
|
||||
|
||||
def render_account_line(account, now, locked, markup=True, with_name=True):
|
||||
"""Render one account: name + lock emoji (optional), then the three
|
||||
bars with their percentage labels, then balance and update age."""
|
||||
limits = account.get("limits") if account else None
|
||||
parts = []
|
||||
if with_name:
|
||||
nm = account.get("account") if account else ""
|
||||
if nm:
|
||||
nm = escape(nm) if markup else nm
|
||||
parts.append(("\U0001f512 " if locked else "") + nm)
|
||||
segs = []
|
||||
for key, label in FRAME_LABELS:
|
||||
f = frame_get(limits, key)
|
||||
if f is None:
|
||||
filled = label.format("\u2014")
|
||||
bar = "" if markup else ""
|
||||
rel = "\u2014"
|
||||
else:
|
||||
used = f.get("used") or 0.0
|
||||
unit = f.get("unit") or ""
|
||||
val = f"{int(round(used))}{unit}"
|
||||
filled = label.format(val)
|
||||
bar = bar_html(pct_of(f), f.get("status"), locked) if markup else bar_plain(pct_of(f), f.get("status"), locked)
|
||||
rel = rel_reset(f.get("resets_at"), now)
|
||||
segs.append(f"{filled} {bar} {rel}")
|
||||
parts.append(" ".join(segs))
|
||||
bal = balance_str(account or {})
|
||||
fa = rel_ago(account.get("fetched_at") if account else None, now)
|
||||
parts.append(f"{bal} / {fa}")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def render_text(report, index, locked, now):
|
||||
accounts = (report or {}).get("accounts") or []
|
||||
if not accounts:
|
||||
return ("ocd: no data", worst_zone(None))
|
||||
idx = index % len(accounts)
|
||||
acct = accounts[idx]
|
||||
line = render_account_line(acct, now, locked, markup=True, with_name=True)
|
||||
return (line, worst_zone(acct, locked))
|
||||
|
||||
|
||||
def render_tooltip(report, index, locked, now):
|
||||
accounts = (report or {}).get("accounts") or []
|
||||
if not accounts:
|
||||
return "ocd: waiting for first poll"
|
||||
gen = parse_dt((report or {}).get("generated_at"))
|
||||
head = "opencode limits"
|
||||
if gen:
|
||||
head += f" \u00b7 updated {rel_ago(report.get('generated_at'), now)}"
|
||||
lines = [head, ""]
|
||||
for i, acct in enumerate(accounts):
|
||||
marker = "\u25b8 " if i == (index % len(accounts)) else " "
|
||||
# The lock emoji belongs only to the currently displayed row.
|
||||
row_locked = locked and i == (index % len(accounts))
|
||||
body = render_account_line(acct, now, row_locked, markup=TOOLTIP_MARKUP, with_name=True)
|
||||
lines.append(f"{marker}{body}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def write_state(text, tooltip, klass):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"text": text,
|
||||
"tooltip": tooltip,
|
||||
"class": klass,
|
||||
"alt": "ocd",
|
||||
}
|
||||
tmp = STATE_FILE.with_suffix(".json.tmp")
|
||||
tmp.write_text(json.dumps(payload, ensure_ascii=False))
|
||||
tmp.replace(STATE_FILE)
|
||||
|
||||
|
||||
def fetch_ocd():
|
||||
"""Run `ocd raw json`. Returns parsed dict or None on failure."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[OCD_BIN, "raw", "json"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return json.loads(proc.stdout)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class Daemon:
|
||||
def __init__(self):
|
||||
self.report = None
|
||||
self.index = 0
|
||||
self.last_advance = time.monotonic()
|
||||
self.locked_until = 0.0
|
||||
self.next_poll = 0.0
|
||||
self.last_text = None
|
||||
self.last_key = None
|
||||
self.sock = None
|
||||
self.running = True
|
||||
|
||||
def event_deadline(self):
|
||||
now = time.monotonic()
|
||||
deadlines = [now + TICK_SECS, self.next_poll]
|
||||
if not self.is_locked(now):
|
||||
deadlines.append(self.last_advance + ROTATE_SECS)
|
||||
else:
|
||||
deadlines.append(self.locked_until)
|
||||
return max(0.0, min(deadlines) - now)
|
||||
|
||||
def is_locked(self, now=None):
|
||||
if now is None:
|
||||
now = time.monotonic()
|
||||
return self.locked_until and now < self.locked_until
|
||||
|
||||
def advance(self):
|
||||
n = len((self.report or {}).get("accounts") or [])
|
||||
if n:
|
||||
self.index = (self.index + 1) % n
|
||||
self.last_advance = time.monotonic()
|
||||
|
||||
def lock(self):
|
||||
self.locked_until = time.monotonic() + LOCK_SECS
|
||||
|
||||
def maybe_tick(self):
|
||||
now = time.monotonic()
|
||||
if now >= self.next_poll:
|
||||
self.next_poll = now + POLL_SECS
|
||||
rep = fetch_ocd()
|
||||
if rep:
|
||||
self.report = rep
|
||||
# keep index in range
|
||||
n = len(rep.get("accounts") or [])
|
||||
if n and self.index >= n:
|
||||
self.index %= n
|
||||
if not self.is_locked(now) and (now - self.last_advance) >= ROTATE_SECS:
|
||||
self.advance()
|
||||
if self.is_locked(now) and now >= self.locked_until:
|
||||
self.locked_until = 0.0
|
||||
|
||||
def render_and_write(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
text, zn = render_text(self.report, self.index, self.is_locked(), now)
|
||||
tooltip = render_tooltip(self.report, self.index, self.is_locked(), now)
|
||||
klass = ("ocd " + zn + (" ocd-locked" if self.is_locked() else "")).strip()
|
||||
# Write only when something changed. Displayed relative times
|
||||
# are minute-resolution, so the file updates a few times per minute.
|
||||
key = (text, tooltip, klass)
|
||||
if key != self.last_key:
|
||||
write_state(text, tooltip, klass)
|
||||
self.last_key = key
|
||||
|
||||
def handle_cmd(self, cmd):
|
||||
cmd = (cmd or "").strip()
|
||||
if cmd == "advance":
|
||||
self.advance()
|
||||
self.locked_until = 0.0
|
||||
elif cmd == "lock":
|
||||
self.lock()
|
||||
elif cmd == "poll":
|
||||
self.next_poll = 0.0
|
||||
elif cmd == "quit":
|
||||
self.running = False
|
||||
|
||||
def setup_socket(self):
|
||||
try:
|
||||
os.unlink(SOCK_FILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
self.sock.bind(str(SOCK_FILE))
|
||||
self.sock.setblocking(False)
|
||||
os.chmod(SOCK_FILE, 0o600)
|
||||
|
||||
def drain_socket(self):
|
||||
if self.sock is None:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
data, _ = self.sock.recvfrom(4096)
|
||||
except BlockingIOError:
|
||||
return
|
||||
self.handle_cmd(data.decode("utf-8", "replace"))
|
||||
|
||||
def stop(self, *_):
|
||||
self.running = False
|
||||
try:
|
||||
if self.sock:
|
||||
self.sock.close()
|
||||
os.unlink(SOCK_FILE)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
d = Daemon()
|
||||
d.setup_socket()
|
||||
signal.signal(signal.SIGTERM, d.stop)
|
||||
signal.signal(signal.SIGINT, d.stop)
|
||||
# force first poll
|
||||
d.next_poll = 0.0
|
||||
while d.running:
|
||||
d.maybe_tick()
|
||||
d.drain_socket()
|
||||
d.render_and_write()
|
||||
time.sleep(d.event_deadline())
|
||||
d.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
30
scripts/ocd-waybar-ctl
Executable file
30
scripts/ocd-waybar-ctl
Executable file
@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# ocd-waybar-ctl: send a command to the ocd-waybar daemon.
|
||||
# Usage: ocd-waybar-ctl {advance|lock|poll|quit}
|
||||
# wired into the waybar custom/ocd module as on-click / on-click-right.
|
||||
|
||||
set -eu
|
||||
cmd="${1:-advance}"
|
||||
sock="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/ocd-waybar.sock"
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
cmd="$1"
|
||||
fi
|
||||
|
||||
case "$cmd" in
|
||||
advance|lock|poll|quit) ;;
|
||||
*) echo "ocd-waybar-ctl: unknown command '$cmd'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if [ ! -S "$sock" ]; then
|
||||
echo "ocd-waybar-ctl: daemon socket not found at $sock" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '%s\n' "$cmd" | socat - UNIX-SENDTO:"$sock" 2>/dev/null \
|
||||
|| printf '%s\n' "$cmd" | nc -U -u -w1 "$sock" 2>/dev/null \
|
||||
|| python3 - "$sock" "$cmd" <<'PY'
|
||||
import socket, sys
|
||||
s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
|
||||
s.sendto((sys.argv[2] + "\n").encode(), sys.argv[1])
|
||||
PY
|
||||
Loading…
Reference in New Issue
Block a user