diff --git a/.github/workflows/ai-usage-collector.yml b/.github/workflows/ai-usage-collector.yml index c21453067..0d18d39de 100644 --- a/.github/workflows/ai-usage-collector.yml +++ b/.github/workflows/ai-usage-collector.yml @@ -13,7 +13,18 @@ on: pull_request: paths: - 'ryoku/rashin/usage/opencode-usage' + - 'ryoku/rashin/usage/codex-usage' + - 'ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/**' - 'tests/opencode-usage.sh' + - 'tests/codex-usage.sh' + - 'tests/codex-usage-regression.py' + - 'tests/ai-usage-cache.py' + - 'tests/ai-usage-widget.py' + - 'tests/custom-widget-install.py' + - 'ryoku/shell/quickshell/inir/services/CustomWidgets.qml' + - 'ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml' + - 'ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml' + - 'ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml' - '.github/workflows/ai-usage-collector.yml' push: branches: @@ -21,7 +32,18 @@ on: - unstable-dev paths: - 'ryoku/rashin/usage/opencode-usage' + - 'ryoku/rashin/usage/codex-usage' + - 'ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/**' - 'tests/opencode-usage.sh' + - 'tests/codex-usage.sh' + - 'tests/codex-usage-regression.py' + - 'tests/ai-usage-cache.py' + - 'tests/ai-usage-widget.py' + - 'tests/custom-widget-install.py' + - 'ryoku/shell/quickshell/inir/services/CustomWidgets.qml' + - 'ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml' + - 'ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml' + - 'ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml' - '.github/workflows/ai-usage-collector.yml' workflow_dispatch: @@ -39,3 +61,24 @@ jobs: - name: Store-layout fixture test shell: bash run: bash tests/opencode-usage.sh + + - name: Codex rollout fixture test + shell: bash + run: bash tests/codex-usage.sh + + - name: Set up Node for widget parser checks + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Codex replay and quota-window regression tests + run: python3 tests/codex-usage-regression.py + + - name: Widget cache contract + shell: bash + run: python3 tests/ai-usage-cache.py + + - name: Widget calendar and shared-loader compatibility + run: | + python3 tests/ai-usage-widget.py + python3 tests/custom-widget-install.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 099cd8352..94c49f162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,11 @@ for finer detail. mutable channels current without changing frozen distro snapshots. ### Added +- **AI usage comes to Shima.** Enable the built-in module in Island settings + under Desktop page / Your modules for OpenCode and Codex sessions, token + trends, model usage, activity and available plan limits. OpenCode shows + recorded costs. Codex shows Est. Spending from saved daily API prices, + with incomplete coverage marked. Estimates are not bills or wallet balances. - **Community docks.** RyoStore gains a Docks section: anyone can submit a dock as a folder to the ryostore catalogue, users install it there and pick it in Edit desktop -> Dock, and it becomes the dock on every monitor and every bar diff --git a/ryoku/rashin/usage/codex-usage b/ryoku/rashin/usage/codex-usage index 0c8fae9b1..ee8d71695 100755 --- a/ryoku/rashin/usage/codex-usage +++ b/ryoku/rashin/usage/codex-usage @@ -8,16 +8,20 @@ recorded session rollout. Codex can expose multiple independent limit buckets, so the cache stores bucket/window metadata instead of assuming Claude's fixed 5h/7d model. """ -import json, os, select, shutil, subprocess, sys, time +import fcntl, hashlib, json, math, os, select, shutil, subprocess, sys, tempfile, time +import urllib.request from datetime import datetime, timedelta, timezone from pathlib import Path CODEX_HOME = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex")) CACHE_FILE = Path(os.environ.get("CODEX_USAGE_CACHE_FILE") or (Path.home() / ".cache" / "codex-usage.json")) ACTIVITY_FILE = Path(os.environ.get("CODEX_USAGE_ACTIVITY_FILE") or (Path.home() / ".cache" / "codex-usage-activity.json")) -ACTIVITY_SCHEMA_VERSION = 3 +ACTIVITY_SCHEMA_VERSION = 4 RESET_PLAUSIBILITY_SLOP_SECS = 600 EVENT_CLOCK_SKEW_SECS = 120 +PRICE_URL = "https://models.dev/api.json" +SPENDING_DIR = Path(os.environ.get("CODEX_USAGE_STATE_DIR") or + (Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state") / "ai-usage")) def now_epoch() -> float: @@ -29,9 +33,18 @@ def now_epoch() -> float: def atomic_write_json(path: Path, obj: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) - tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") - tmp.write_text(json.dumps(obj, separators=(",", ":")) + "\n") - os.replace(tmp, path) + tmp = None + try: + # mkstemp permissions stay private even when the caller has umask 022. + with tempfile.NamedTemporaryFile(mode="w", dir=path.parent, + prefix=f".{path.name}.", delete=False) as handle: + tmp = Path(handle.name) + json.dump(obj, handle, separators=(",", ":")) + handle.write("\n") + os.replace(tmp, path) + finally: + if tmp is not None: + tmp.unlink(missing_ok=True) def runtime_env() -> dict: @@ -86,20 +99,37 @@ def rpc_request(proc, request_id, method, params=None, timeout=8): proc.stdin.write(json.dumps({"id": request_id, "method": method, "params": params or {}}) + "\n") proc.stdin.flush() - deadline = time.time() + timeout - while time.time() < deadline: - ready, _, _ = select.select([proc.stdout], [], [], 0.25) - if not ready: - continue - line = proc.stdout.readline() - if not line: - break - try: - msg = json.loads(line) - except Exception: - continue - if msg.get("id") == request_id: + deadline = time.monotonic() + timeout + pending = getattr(proc, "_usage_rpc_pending", b"") + try: + while time.monotonic() < deadline: + if b"\n" not in pending: + ready, _, _ = select.select([proc.stdout], [], [], + min(0.25, max(0, deadline - time.monotonic()))) + if not ready: + continue + chunk = os.read(proc.stdout.fileno(), 65536) + if not chunk: + break + pending += chunk + line, separator, remainder = pending.partition(b"\n") + if len(line) > 1024 * 1024: + raise RuntimeError("RPC response exceeds size limit") + if not separator: + continue + pending = remainder + try: + msg = json.loads(line) + except (ValueError, UnicodeError): + continue + if not isinstance(msg, dict) or msg.get("id") != request_id: + continue + if "error" in msg: + # Error bodies belong to the provider and may contain secrets. + raise RuntimeError(f"{method} failed") return msg + finally: + proc._usage_rpc_pending = pending raise TimeoutError(method) @@ -111,32 +141,57 @@ def fetch_rpc() -> dict | None: return None try: proc = subprocess.Popen( - [codex, "-s", "read-only", "-a", "untrusted", "app-server"], + [codex, "-s", "read-only", "-a", "never", "app-server", "--listen", "stdio://"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, env=ENV) - except Exception as e: - sys.stderr.write(f"app-server spawn failed: {e}\n") + except Exception: + sys.stderr.write("app-server spawn failed\n") return None try: - rpc_request(proc, 1, "initialize", - {"clientInfo": {"name": "codex-usage", "version": "1"}}, timeout=8) - proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n") - proc.stdin.flush() - account = (rpc_request(proc, 2, "account/read", timeout=5) - .get("result") or {}).get("account") or {} - rate_limits_result = (rpc_request(proc, 3, "account/rateLimits/read", timeout=5) - .get("result") or {}) - except Exception as e: - sys.stderr.write(f"rpc error: {e}\n") - return None + try: + rpc_request(proc, 1, "initialize", + {"clientInfo": {"name": "codex-usage", "version": "1"}}, timeout=15) + proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n") + proc.stdin.flush() + except Exception: + sys.stderr.write("rpc error: initialize\n") + return None + # The account call only supplies a plan fallback; the rate limits carry + # planType themselves. A slow or unauthenticated account read must not + # cost the live percentages. + account = {} + try: + account = (rpc_request(proc, 2, "account/read", timeout=6) + .get("result") or {}).get("account") or {} + except Exception: + pass + if str(account.get("type") or "").lower() in ("apikey", "amazonbedrock"): + # These accounts have no ChatGPT plan windows. An explicit empty + # result prevents historical ChatGPT rollouts becoming live quotas. + return {"rateLimits": {}, "rateLimitsByLimitId": None, + "plan": "", "source": "rpc"} + try: + rate_limits_result = (rpc_request(proc, 3, "account/rateLimits/read", timeout=10) + .get("result") or {}) + except Exception: + sys.stderr.write("rpc error: account/rateLimits/read\n") + return None finally: try: - proc.terminate(); proc.wait(timeout=1) + proc.terminate() + proc.wait(timeout=1) except Exception: try: proc.kill() + proc.wait(timeout=1) except Exception: pass + for stream in (proc.stdin, proc.stdout): + if stream is not None: + try: + stream.close() + except OSError: + pass limits = rate_limits_result.get("rateLimits") or {} by_limit = rate_limits_result.get("rateLimitsByLimitId") @@ -478,6 +533,15 @@ def local_day_start_epoch(now: float) -> float: return start.timestamp() +def token_event_key(session_id: str, event: dict) -> str: + # A rollout may move or be copied. Its event identity must survive a path + # change while keeping separate sessions and genuine counter resets apart. + info = (event.get("payload") or {}).get("info") or {} + usage = info.get("total_token_usage") or info + identity = [session_id, event.get("timestamp"), usage] + return hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest() + + def load_activity_state() -> dict: try: state = json.loads(ACTIVITY_FILE.read_text()) @@ -506,6 +570,11 @@ def scan_activity() -> tuple[int, int, dict[int, dict]]: events = state.get("events") if isinstance(state.get("events"), list) else [] rate_limit_events = state.get("rateLimitEvents") if isinstance(state.get("rateLimitEvents"), list) else [] daily = state.get("dailyTokens") if isinstance(state.get("dailyTokens"), dict) else {} + # Match the retained daily history, so replaying yesterday's file cannot + # add its tokens again after its rolling-hour events have been pruned. + seen_cutoff = day_start - 8 * 86400 + seen = {key: stamp for key, stamp in (state.get("seenTokenEvents") or {}).items() + if as_float(stamp) >= seen_cutoff} initial_rate_limit_scan = not files and not rate_limit_events pruned_events = [] @@ -583,19 +652,23 @@ def scan_activity() -> tuple[int, int, dict[int, dict]]: meta = files.get(key) if isinstance(files.get(key), dict) else {} pos = as_int(meta.get("pos")) if st.st_size >= as_int(meta.get("pos")) else 0 last_total = as_int(meta.get("last_total")) if pos > 0 else 0 + session_id = str(meta.get("sessionId") or p.stem) if pos == st.st_size and as_float(meta.get("mtime")) == st.st_mtime: continue try: with p.open(errors="replace") as fh: fh.seek(pos) for raw in fh: - if '"token_count"' not in raw: + if '"token_count"' not in raw and '"session_meta"' not in raw: continue try: ev = json.loads(raw) except Exception: continue payload = ev.get("payload") or {} + if ev.get("type") == "session_meta": + session_id = str(payload.get("id") or payload.get("session_id") or session_id) + continue if payload.get("type") != "token_count": continue ts = iso_epoch(str(ev.get("timestamp") or "")) @@ -621,6 +694,11 @@ def scan_activity() -> tuple[int, int, dict[int, dict]]: last_total = total else: delta = token_last(info) + identity = token_event_key(session_id, ev) + if identity in seen: + continue + if ts >= seen_cutoff: + seen[identity] = ts if delta > 0: events.append([ts, int(delta)]) day = datetime.fromtimestamp(ts).astimezone().strftime("%Y-%m-%d") @@ -628,7 +706,8 @@ def scan_activity() -> tuple[int, int, dict[int, dict]]: pos = fh.tell() except OSError: continue - files[key] = {"pos": pos, "mtime": st.st_mtime, "size": st.st_size, "last_total": last_total} + files[key] = {"pos": pos, "mtime": st.st_mtime, "size": st.st_size, + "last_total": last_total, "sessionId": session_id} recent = today = 0 today_date = datetime.fromtimestamp(now).astimezone().date() @@ -670,6 +749,7 @@ def scan_activity() -> tuple[int, int, dict[int, dict]]: "events": events, "rateLimitEvents": rate_limit_events, "dailyTokens": daily, + "seenTokenEvents": seen, } try: atomic_write_json(ACTIVITY_FILE, state) @@ -678,10 +758,568 @@ def scan_activity() -> tuple[int, int, dict[int, dict]]: return recent, today, best_windows, recent_days_list +HISTORY_DAYS = 95 + + +def fmt_tokens(value) -> str: + value = as_int(value) + if value >= 1_000_000: + return f"{value / 1_000_000:.1f}M" + if value >= 1000: + return f"{value / 1000:.1f}K" + return str(value) + + +def model_label(model_id: str, provider_id: str) -> str: + if provider_id and model_id: + return f"{model_id} ({provider_id})" + return model_id or provider_id or "unknown" + + +def history_records() -> tuple[list[dict], dict]: + """Per-event usage and per-session rows from the rollout files. + + Codex records cumulative token counts per turn, so each event contributes + the delta since the previous one in its file. That keeps the day buckets in + agreement with the Today figure the same events produce. Records are split + by timestamp, so a session that spans days lands on the days it ran. + """ + roots = [CODEX_HOME / "sessions", CODEX_HOME / "archived_sessions"] + now = now_epoch() + cutoff = now - HISTORY_DAYS * 24 * 3600 + records: list[dict] = [] + sessions: dict[str, dict] = {} + seen: set[str] = set() + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*.jsonl"): + try: + st = path.stat() + except OSError: + continue + if st.st_mtime < cutoff: + continue + session_id = path.stem + title = "" + cwd = "" + model_id = "" + provider_id = "" + started = 0 + updated = 0 + last_total = 0 + last_cached = 0 + last_output = 0 + last_write = 0 + service_tier = "standard" + lifecycle_ts = 0.0 + lifecycle_running = None + try: + with path.open(errors="replace") as handle: + for raw in handle: + # Skip large tool and message records with one substring + # test instead of parsing every line as JSON. The title + # scan stops once a real user message has named the + # session. + if ('"session_meta"' not in raw + and '"thread_settings_applied"' not in raw + and '"turn_context"' not in raw + and '"event_msg"' not in raw + and (title or '"response_item"' not in raw)): + continue + try: + event = json.loads(raw) + except ValueError: + continue + kind = str(event.get("type") or "") + payload = event.get("payload") or {} + stamp = iso_epoch(str(event.get("timestamp") or "")) + if stamp > 0: + updated = max(updated, stamp) + if kind == "session_meta": + session_id = str(payload.get("id") or payload.get("session_id") or session_id) + cwd = str(payload.get("cwd") or cwd) + stamp = iso_epoch(str(payload.get("timestamp") or "")) or stamp + started = stamp if started <= 0 else min(started, stamp) + updated = max(updated, stamp) + continue + if kind == "thread_settings_applied": + settings = payload.get("thread_settings") or {} + model_id = str(settings.get("model") or model_id) + provider_id = str(settings.get("model_provider_id") or provider_id) + service_tier = str(settings.get("service_tier") or service_tier) + continue + if kind == "turn_context": + model_id = str(payload.get("model") or model_id) + service_tier = str(payload.get("service_tier") or service_tier) + continue + if kind == "event_msg": + turn_event = str(payload.get("type") or "") + if turn_event == "task_started" or turn_event in ( + "task_complete", "turn_aborted", "error"): + if stamp >= lifecycle_ts: + lifecycle_ts = stamp + lifecycle_running = turn_event == "task_started" + if kind == "response_item" and payload.get("type") == "message" \ + and payload.get("role") == "user" and not title: + text = "" + for part in (payload.get("content") or []): + if isinstance(part, dict) and part.get("type") == "input_text": + text = str(part.get("text") or "") + break + text = " ".join(text.split()) + if text and not text.startswith("# AGENTS.md instructions") \ + and not text.startswith(""): + title = text[:72] + continue + if kind != "event_msg" or payload.get("type") != "token_count": + continue + info = payload.get("info") or {} + cumulative = info.get("total_token_usage") or {} + if not cumulative or not stamp: + continue + total = as_int(cumulative.get("total_tokens")) + cached = as_int(cumulative.get("cached_input_tokens")) + output = as_int(cumulative.get("output_tokens")) + written = as_int(cumulative.get("cache_write_input_tokens")) + delta = total - last_total if total >= last_total else total + cached_delta = cached - last_cached if cached >= last_cached else cached + output_delta = output - last_output if output >= last_output else output + write_delta = written - last_write if written >= last_write else written + last_total = total + last_cached = cached + last_output = output + last_write = written + identity = token_event_key(session_id, event) + if identity in seen: + continue + seen.add(identity) + tokens = max(0, delta - cached_delta) + output_tokens = max(0, output_delta) + if tokens <= 0 and cached_delta <= 0: + continue + records.append({ + "event_id": identity, + "session_id": session_id, + "model_id": model_id, + "provider_id": provider_id, + "input": max(0, tokens - output_tokens), + "output": output_tokens, + "reasoning": 0, + "cache_read": max(0, cached_delta), + "cache_write": 0, + "total": tokens, + "total_with_cache_read": tokens + max(0, cached_delta), + "cost": 0.0, + "time": int(stamp * 1000), + "pricing_input": max(0, tokens - output_tokens - write_delta), + "pricing_write": max(0, write_delta), + "service_tier": service_tier, + }) + except OSError: + continue + if not session_id: + session_id = path.stem + session = sessions.setdefault(session_id, { + "id": session_id, + "title": "", + "directory": cwd, + "started": started, + "updated": updated, + "tokens": 0, + "cost": 0.0, + "running": False, + }) + if not session["title"]: + session["title"] = title + if not session["directory"]: + session["directory"] = cwd + if started > 0: + session["started"] = started if session["started"] <= 0 else min(session["started"], started) + session["updated"] = max(session["updated"], updated) + if lifecycle_ts >= session.get("_lifecycle_ts", 0): + session["_lifecycle_ts"] = lifecycle_ts + session["_running_state"] = lifecycle_running + messages = sorted(records, key=lambda row: row["time"]) + for message in messages: + session = sessions.get(message["session_id"]) + if session is None: + continue + session["tokens"] += message["total"] + session["cost"] += float(message["cost"]) + for session in sessions.values(): + if not session["title"]: + directory = str(session["directory"] or "").rstrip("/") + session["title"] = directory.rsplit("/", 1)[-1] if directory else "Codex session" + state = session.pop("_running_state", None) + session.pop("_lifecycle_ts", None) + if state is None: + session["running"] = session["updated"] > 0 \ + and (now - session["updated"]) < 600 + else: + session["running"] = bool(state) \ + and session["updated"] > 0 \ + and (now - session["updated"]) < 1800 + return messages, sessions + + +def history_sessions(sessions: dict, limit: int = 10) -> list[dict]: + rows = sorted(sessions.values(), key=lambda row: row["updated"], reverse=True)[:limit] + return [{ + "id": row["id"], + "title": row["title"], + "directory": row["directory"], + "started": int(row["started"] * 1000) if row["started"] < 10**11 else int(row["started"]), + "updated": int(row["updated"] * 1000) if row["updated"] < 10**11 else int(row["updated"]), + "tokens": int(row["tokens"]), + "cost": round(float(row["cost"]), 4), + "running": bool(row["running"]), + } for row in rows] + + +def history_period(messages: list[dict], since_ms: int, limit: int = 8) -> dict: + by_model: dict = {} + tokens = cache_read = with_cache = 0 + cost = 0.0 + sessions: set = set() + for message in messages: + if message["time"] < since_ms: + continue + key = (message["provider_id"], message["model_id"]) + row = by_model.setdefault(key, { + "name": model_label(message["model_id"], message["provider_id"]), + "total": 0, "input": 0, "output": 0, "reasoning": 0, + "cache_read": 0, "cache_write": 0, "cost": 0.0, + "last_seen": 0, "sessions": set(), + }) + row["total"] += message["total"] + row["input"] += message["input"] + row["output"] += message["output"] + row["reasoning"] += message["reasoning"] + row["cache_read"] += message["cache_read"] + row["cache_write"] += message["cache_write"] + row["cost"] += float(message["cost"]) + row["last_seen"] = max(row["last_seen"], message["time"]) + if message["session_id"]: + row["sessions"].add(message["session_id"]) + sessions.add(message["session_id"]) + tokens += message["total"] + cache_read += message["cache_read"] + with_cache += message["total_with_cache_read"] + cost += float(message["cost"]) + + def weight(row): + return row["total"] + row["cache_read"] + + ranked = sorted(by_model.values(), key=weight, reverse=True)[:limit] + peak = max((weight(row) for row in ranked), default=0) + models = [] + for row in ranked: + total = int(row["total"]) + cache_total = total + int(row["cache_read"]) + models.append({ + "name": row["name"], + "total": total, + "totalLabel": fmt_tokens(total), + "totalWithCacheRead": cache_total, + "totalWithCacheReadLabel": fmt_tokens(cache_total), + "inputLabel": fmt_tokens(int(row["input"])), + "outputLabel": fmt_tokens(int(row["output"])), + "reasoningLabel": fmt_tokens(int(row["reasoning"])), + "cacheReadLabel": fmt_tokens(int(row["cache_read"])), + "cacheWriteLabel": fmt_tokens(int(row["cache_write"])), + "cacheRate": round(row["cache_read"] / cache_total, 4) if cache_total else 0.0, + "cost": round(float(row["cost"]), 4), + "costLabel": f"${row['cost']:.2f}" if row["cost"] > 0.0001 else "", + "sessions": len(row["sessions"]), + "lastSeen": int(row["last_seen"]), + "pct": int(round(cache_total * 100 / peak)) if peak else 0, + }) + return { + "tokens": int(tokens), + "tokensLabel": fmt_tokens(int(tokens)), + "cacheRate": round(cache_read / with_cache, 4) if with_cache else 0.0, + "cost": round(cost, 4), + "costLabel": f"${cost:.2f}" if cost > 0.0005 else "", + "sessions": len(sessions), + "models": models, + } + + +def history_days(messages: list[dict]) -> list[dict]: + if not messages: + return [] + today = datetime.fromtimestamp(now_epoch()).astimezone().date() + first = datetime.fromtimestamp(messages[0]["time"] / 1000).astimezone().date() + span = max(1, min((today - first).days + 1, HISTORY_DAYS)) + start = today - timedelta(days=span - 1) + start_ms = int(datetime.combine(start, datetime.min.time()).astimezone().timestamp() * 1000) + by_day: dict[str, dict] = {} + for message in messages: + if message["time"] < start_ms: + continue + key = datetime.fromtimestamp(message["time"] / 1000).astimezone().strftime("%Y-%m-%d") + bucket = by_day.setdefault(key, {"tokens": 0, "cacheRead": 0, "cost": 0.0}) + bucket["tokens"] += message["total"] + bucket["cacheRead"] += message["cache_read"] + bucket["cost"] += float(message["cost"]) + return [{ + "date": (today - timedelta(days=i)).strftime("%Y-%m-%d"), + "tokens": int(by_day.get((today - timedelta(days=i)).strftime("%Y-%m-%d"), {}).get("tokens", 0)), + "cacheRead": int(by_day.get((today - timedelta(days=i)).strftime("%Y-%m-%d"), {}).get("cacheRead", 0)), + "cost": round(float(by_day.get((today - timedelta(days=i)).strftime("%Y-%m-%d"), {}).get("cost", 0.0)), 6), + } for i in range(span - 1, -1, -1)] + + +def history_hours(messages: list[dict], hours: int = 48) -> list[dict]: + # Buckets are local clock hours: the widget builds its 24 slots from local + # midnight, so UTC-aligned keys never meet the chart in a half-hour zone. + now_local = datetime.fromtimestamp(now_epoch()).astimezone().replace( + minute=0, second=0, microsecond=0) + current = int(now_local.timestamp()) + by_hour: dict[int, dict] = {} + for message in messages: + moment = datetime.fromtimestamp(message["time"] / 1000).astimezone().replace( + minute=0, second=0, microsecond=0) + bucket = int(moment.timestamp()) + if bucket < current - (hours - 1) * 3600: + continue + row = by_hour.setdefault(bucket, {"tokens": 0, "cacheRead": 0}) + row["tokens"] += message["total"] + row["cacheRead"] += message["cache_read"] + return [{ + "start": int((current - offset * 3600) * 1000), + "tokens": int(by_hour.get(int(current - offset * 3600), {}).get("tokens", 0)), + "cacheRead": int(by_hour.get(int(current - offset * 3600), {}).get("cacheRead", 0)), + } for offset in range(hours - 1, -1, -1)] + + +def merge_published(key: str, fresh: list[dict], previous: dict) -> list[dict]: + """Keep published buckets monotonic: a day or hour never loses tokens.""" + if not isinstance(previous, dict): + return fresh + old_rows = previous.get(key) + if not isinstance(old_rows, list): + return fresh + field = "date" if key == "_days" else "start" + if key == "_hours": + # Caches written before the milliseconds switch keep second keys; the + # widget normalises them, but the cache itself must not carry ghosts. + migrated = [] + for old in old_rows: + row = dict(old) + start = as_int(row.get("start")) + if 0 < start < 10 ** 11: + row["start"] = start * 1000 + if as_int(row.get("start")) > 0: + migrated.append(row) + old_rows = migrated + merged = {str(row.get(field)): dict(row) for row in fresh} + for old in old_rows: + slot = str(old.get(field) or "") + if not slot: + continue + current = merged.get(slot) + if current is None: + merged[slot] = dict(old) + continue + current["tokens"] = max(as_int(current.get("tokens")), as_int(old.get("tokens"))) + current["cacheRead"] = max(as_int(current.get("cacheRead")), as_int(old.get("cacheRead"))) + current["cost"] = max(as_float(current.get("cost")), as_float(old.get("cost"))) + if key == "_hours": + return sorted(merged.values(), key=lambda row: as_int(row.get(field))) + return sorted(merged.values(), key=lambda row: str(row.get(field))) + + +def published_codes() -> dict: + try: + return json.loads(CACHE_FILE.read_text()) + except Exception: + return {} + + +def normalize_prices(catalog: dict) -> dict: + """Keep only numeric OpenAI token rates and explicit context tiers.""" + models = (catalog.get("openai") or {}).get("models") or {} + result = {} + + def rates(cost): + clean = {} + for key in ("input", "output", "cache_read", "cache_write"): + value = cost.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool) \ + and math.isfinite(value) and 0 <= value <= 1_000_000: + clean[key] = float(value) + return clean if "input" in clean and "output" in clean else None + + for name, model in models.items(): + if not isinstance(model, dict) or not isinstance(model.get("cost"), dict): + continue + cost = model["cost"] + clean = rates(cost) + if clean is None: + continue + tiers = [] + valid = True + for tier in cost.get("tiers") or []: + spec = tier.get("tier") or {} + row = rates(tier) + size = as_int(spec.get("size")) + if spec.get("type") != "context" or size <= 0 or row is None: + valid = False + break + tiers.append(dict(row, above=size)) + if valid: + result[str(name)] = dict(clean, tiers=sorted(tiers, key=lambda row: row["above"])) + return result + + +def fetch_prices() -> dict | None: + """Public metadata only: no Codex credentials, proxies or redirects.""" + fixture = os.environ.get("CODEX_USAGE_PRICES_JSON") + try: + if fixture is not None: + return normalize_prices(json.loads(fixture)) or None + if os.environ.get("CODEX_USAGE_DISABLE_PRICES") == "1": + return None + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + request = urllib.request.Request(PRICE_URL, headers={ + "Accept": "application/json", "User-Agent": "ryoku-ai-usage/1"}) + with opener.open(request, timeout=8) as response: + raw = response.read(16 * 1024 * 1024 + 1) + if len(raw) > 16 * 1024 * 1024: + return None + return normalize_prices(json.loads(raw)) or None + except Exception: + return None + + +def spending_ledger(month: str) -> dict | None: + path = SPENDING_DIR / f"spending-{month}.json" + try: + value = json.loads(path.read_text()) + if value.get("schemaVersion") == 1 and value.get("month") == month \ + and isinstance(value.get("days"), dict) and isinstance(value.get("events"), dict): + return value + except FileNotFoundError: + return {"schemaVersion": 1, "month": month, "currency": "USD", "days": {}, "events": {}} + except Exception: + pass + # Never overwrite an unreadable historical price ledger. + return None + + +def price_event(event: dict, prices: dict) -> float | None: + if event.get("provider") not in ("", "openai") \ + or event.get("service_tier") not in ("standard", "default", "auto"): + return None + rate = prices.get(event["model"]) + if not isinstance(rate, dict): + return None + for tier in rate.get("tiers", []): + if event["context_input"] > tier["above"]: + rate = tier + total = 0.0 + for key in ("input", "output", "cache_read", "cache_write"): + count = event[key] + value = rate.get(key) + if count and (value is None or not math.isfinite(value) or value < 0): + return None + total += count * (value or 0) / 1_000_000 + return round(total, 10) + + +def estimate_spending(messages: list[dict]) -> dict: + now = now_epoch() + today = datetime.fromtimestamp(now).astimezone().strftime("%Y-%m-%d") + month = today[:7] + summary = {"kind": "api-equivalent", "month": month, "currency": "USD", "amount": None, + "partial": True, "priced_tokens": 0, "unpriced_tokens": 0, + "priced_days": [], "unpriced_days": [], "stale_prices": False} + grouped = {} + for message in messages: + day = datetime.fromtimestamp(message["time"] / 1000).astimezone().strftime("%Y-%m-%d") + grouped.setdefault(day[:7], []).append((day, message)) + grouped.setdefault(month, []) + SPENDING_DIR.mkdir(parents=True, mode=0o700, exist_ok=True) + for key, rows in sorted(grouped.items()): + ledger = spending_ledger(key) + if ledger is None: + continue + days, events = ledger["days"], ledger["events"] + if key == month: + snapshot = days.setdefault(today, {}) + if (not snapshot.get("prices") or snapshot.get("stale_prices")) \ + and now - snapshot.get("last_attempt", 0) >= 900: + snapshot["last_attempt"] = now + prices = fetch_prices() + if prices: + snapshot.update(prices=prices, price_date=today, fetched_at=now, + source=PRICE_URL, stale_prices=False) + elif not snapshot.get("prices"): + candidates = [value for date, value in days.items() + if date < today and value.get("prices")] + if not candidates: + previous_month = (datetime.fromtimestamp(now).astimezone().date() + .replace(day=1) - timedelta(days=1)).strftime("%Y-%m") + previous = spending_ledger(previous_month) + candidates = [value for value in (previous or {}).get("days", {}).values() + if value.get("prices")] + if candidates: + old = max(candidates, key=lambda row: row.get("fetched_at", 0)) + snapshot.update(prices=old["prices"], price_date=old["price_date"], + fetched_at=old["fetched_at"], source=PRICE_URL, stale_prices=True) + for day, message in rows: + events[message["event_id"]] = { + "date": day, "model": message["model_id"], "provider": message["provider_id"], + "input": message["pricing_input"], "output": message["output"], + "cache_read": message["cache_read"], "cache_write": message["pricing_write"], + "context_input": message["input"] + message["cache_read"], + "service_tier": message["service_tier"]} + days.setdefault(day, {}) + for day in days: + days[day].update(cost=0.0, priced_tokens=0, unpriced_tokens=0, models={}) + for event in events.values(): + day = days[event["date"]] + price = price_event(event, day.get("prices", {})) + tokens = sum(event[name] for name in ("input", "output", "cache_read", "cache_write")) + model = day["models"].setdefault(event["model"], { + "input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "cost": 0.0}) + for name in ("input", "output", "cache_read", "cache_write"): + model[name] += event[name] + if price is None: + day["unpriced_tokens"] += tokens + else: + day["priced_tokens"] += tokens + day["cost"] += price + model["cost"] += price + for day in days.values(): + day["cost"] = round(day["cost"], 8) + day["partial"] = day["unpriced_tokens"] > 0 + ledger["updated_at"] = now + atomic_write_json(SPENDING_DIR / f"spending-{key}.json", ledger) + if key == month: + priced = [date for date, value in days.items() if value["priced_tokens"] > 0] + missing = [date for date, value in days.items() if value["unpriced_tokens"] > 0] + summary.update( + amount=round(sum(value["cost"] for value in days.values()), 6) if priced else None, + partial=bool(missing), priced_tokens=sum(value["priced_tokens"] for value in days.values()), + unpriced_tokens=sum(value["unpriced_tokens"] for value in days.values()), + priced_days=sorted(priced), unpriced_days=sorted(missing), + stale_prices=any(value.get("stale_prices", False) for value in days.values()), + price_date=days.get(today, {}).get("price_date", "")) + return summary + + def main(): data = fetch_env() or fetch_rpc() or fetch_rollout() - - if not data: + messages, sessions = history_records() + if not data and not messages: # keep the last numbers on screen, marked stale if CACHE_FILE.exists(): try: @@ -692,14 +1330,30 @@ def main(): pass return + data = data or {"source": "local", "plan": ""} + buckets = normalize_buckets(data) recent_tok, today_tok, session_windows, recent_days_list = scan_activity() - supplement_missing_general_windows_from_sessions(buckets, session_windows) + # Honour the selected snapshot as a whole. Filling its absent windows from + # other session snapshots can restore quotas from a previous plan. general = general_bucket(buckets) windows = general.get("windows") or [] max_util, quota_bucket, quota_window_label = quota_window(general) reached_type = str(general.get("rateLimitReachedType") or "") + try: + spending = estimate_spending(messages) + except Exception: + spending = {"kind": "api-equivalent", "amount": None, "partial": True} + previous = published_codes() + days = merge_published("_days", history_days(messages), previous) + hours = merge_published("_hours", history_hours(messages), previous) + now_ms = int(now_epoch() * 1000) + start_of_day = int(datetime.combine( + datetime.fromtimestamp(now_epoch()).astimezone().date(), + datetime.min.time()).astimezone().timestamp() * 1000) + newest = messages[-1] if messages else {} + out = { "schemaVersion": 3, "buckets": buckets, @@ -718,6 +1372,20 @@ def main(): "_rate_label": "Local activity (1h, incl. cached)", "_today_tokens": today_tok, "_recent_days": recent_days_list, + "_days": days, + "_spending": spending, + "_hours": hours, + "_sessions": history_sessions(sessions), + "_windows": windows, + "_periods": { + "today": history_period(messages, start_of_day), + "24h": history_period(messages, now_ms - 24 * 3600 * 1000), + "7": history_period(messages, start_of_day - 6 * 24 * 3600 * 1000), + "30": history_period(messages, start_of_day - 29 * 24 * 3600 * 1000), + "all": history_period(messages, 0), + }, + "_model": model_label(str(newest.get("model_id") or ""), + str(newest.get("provider_id") or "")), "status": "rejected" if reached_type or max_util >= 1.0 else "allowed_warning" if max_util >= 0.9 else "allowed", @@ -733,4 +1401,13 @@ def main(): if __name__ == "__main__": - main() + CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + lock_fd = os.open(str(CACHE_FILE) + ".lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600) + try: + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + sys.exit(0) + main() + finally: + os.close(lock_fd) diff --git a/ryoku/rashin/usage/opencode-usage b/ryoku/rashin/usage/opencode-usage index 7282aef9c..0ae258195 100755 --- a/ryoku/rashin/usage/opencode-usage +++ b/ryoku/rashin/usage/opencode-usage @@ -1,13 +1,26 @@ #!/usr/bin/env python3 -"""OpenCode local usage for the Quickshell AI usage widget. - -OpenCode has no local 5h/7d quota, so this reads its local session store and -writes the cache shape the widget understands, with soft caps for the bars. No -API tokens, no network. Storage moved across versions, so try each in turn: -filesystem JSON current (/storage/message//*.json), the legacy -session/message path, the 2.x sqlite session_message table, then the oldest -sqlite opencode.db message table. +"""OpenCode local usage for the AI usage widget. + +Reads OpenCode's local session store and writes the cache shape the widget +understands, with soft caps for the bars. No API tokens, no network. Storage +moved across versions, so try each in turn: + + - legacy: storage/message//.json files + - 2.x: sqlite `session_message` rows with their own tokens, model, cost and + `time.created` + - oldest: sqlite `opencode.db` message table + +The 2.x message store is the accurate history: every assistant message and +compaction call carries its own timestamp, so a session that spans days lands +on the days it ran. Session aggregates still true up each session's totals when +the message store misses a row (a retried or pruned request); that difference +is spread across the session's own messages in proportion to their tokens. +Before this, one aggregate per session keyed by time_updated dumped a whole +multi-day session, and any bulk migration that touched old sessions, onto the +day of that touch. """ +import argparse +import fcntl import glob import json import os @@ -16,6 +29,8 @@ import time from datetime import datetime, timedelta from pathlib import Path +VERSION = "2.2.0" + DATA_DIR = Path(os.environ.get("OPENCODE_DATA_DIR") or (Path( os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode")) STORAGE_DIR = DATA_DIR / "storage" @@ -25,10 +40,11 @@ CACHE_FILE = Path.home() / ".cache" / "opencode-usage.json" DEFAULT_5H_LIMIT = 1_000_000 DEFAULT_7D_LIMIT = 20_000_000 -# Every window the widget shows is <= 7 days, so a message older than 8 days can -# never move a bar. Skipping those files by mtime bounds the walk for a heavy -# user with thousands of sessions instead of parsing the whole history each run. WINDOW_HORIZON_S = 8 * 24 * 3600 +# The shared cache keeps the shell chip's 8-day window for its totals and model +# rows; `_days` carries one bucket per day from the first recorded message so a +# reader can offer 60/90/180/all-time views without touching OpenCode itself. +HISTORY_DAYS = 730 def env_int(name: str, default: int) -> int: @@ -75,8 +91,6 @@ def model_label(model_id: str, provider_id: str = "") -> str: def msg_time_ms(data: dict) -> int: - """Assistant messages carry time as {created, completed} in ms; the sqlite - layout carried plain millisecond columns. Accept either.""" t = data.get("time") if isinstance(t, dict): return as_int(t.get("completed") or t.get("created")) @@ -84,8 +98,6 @@ def msg_time_ms(data: dict) -> int: def parse_message(data: dict, session_hint: str = "") -> dict | None: - """Turn one message info dict into the usage record the aggregation uses, or - None when it is not a token-bearing assistant turn.""" if not isinstance(data, dict) or data.get("role") != "assistant": return None model_id = data.get("modelID") or "" @@ -98,8 +110,6 @@ def parse_message(data: dict, session_hint: str = "") -> dict | None: reasoning = as_int(tokens.get("reasoning")) cache_read = as_int(cache.get("read")) cache_write = as_int(cache.get("write")) - # cache_read is server-side context replay. Keep it visible in the model - # breakdown, but exclude it from usage caps so the 5h bar can move. total = inp + output + reasoning + cache_write return { "session_id": data.get("sessionID") or session_hint, @@ -118,13 +128,11 @@ def parse_message(data: dict, session_hint: str = "") -> dict | None: def load_fs_messages(cutoff_s: float) -> list[dict]: - """Walk OpenCode's per-file JSON message store (current and legacy paths), - skipping files older than the window horizon by mtime.""" if not STORAGE_DIR.is_dir(): return [] patterns = [ - str(STORAGE_DIR / "message" / "*" / "*.json"), # current - str(STORAGE_DIR / "session" / "message" / "*" / "*.json"), # legacy + str(STORAGE_DIR / "message" / "*" / "*.json"), + str(STORAGE_DIR / "session" / "message" / "*" / "*.json"), ] out: list[dict] = [] for pattern in patterns: @@ -143,13 +151,17 @@ def load_fs_messages(cutoff_s: float) -> list[dict]: return out +def _connect_ro(): + con = sqlite3.connect(f"file:{DB_FILE}?mode=ro", uri=True, timeout=2.0) + con.execute("pragma busy_timeout=2000") + return con + + def load_sqlite_messages(cutoff_ms: int) -> list[dict]: - """Oldest layout: a single opencode.db with a message(data) table.""" if not DB_FILE.exists(): return [] try: - con = sqlite3.connect(f"file:{DB_FILE}?mode=ro", uri=True, timeout=2.0) - con.execute("pragma busy_timeout=2000") + con = _connect_ro() except sqlite3.Error: return [] out: list[dict] = [] @@ -178,103 +190,266 @@ def load_sqlite_messages(cutoff_ms: int) -> list[dict]: return out -def parse_message_v2(blob: dict, type_value: str, session_hint: str) -> dict | None: - """Turn one 2.x session_message blob into the usage record. 2.x carries the - role in the row's `type` column and nests the model as {id, providerID}, so - it is normalized into the flat shape parse_message expects before reuse.""" - if not isinstance(blob, dict): +def parse_v2_message(kind: str, data: dict, session_id: str, fallback_ms: int = 0) -> dict | None: + """One usage row from the v2 session_message store. + + Assistant messages and compaction calls both carry `tokens` and `model`; + compaction is a real model call and session_v2 counts it, so it belongs in + the history too. + """ + if kind not in ("assistant", "compaction"): return None - role = type_value or blob.get("role") or "" - if role != "assistant": + model = data.get("model") or {} + model_id = str(model.get("id") or model.get("modelID") or "") + if not model_id: return None - model = blob.get("model") if isinstance(blob.get("model"), dict) else {} - flat = dict(blob) - flat["role"] = role - flat["modelID"] = blob.get("modelID") or model.get("id") or "" - flat["providerID"] = blob.get("providerID") or model.get("providerID") or "" - return parse_message(flat, session_hint) + provider_id = str(model.get("providerID") or "") + tokens = data.get("tokens") or {} + cache = tokens.get("cache") or {} + inp = as_int(tokens.get("input")) + output = as_int(tokens.get("output")) + reasoning = as_int(tokens.get("reasoning")) + cache_read = as_int(cache.get("read")) + cache_write = as_int(cache.get("write")) + stamp = data.get("time") or {} + moment = as_int(stamp.get("completed") or stamp.get("created")) if isinstance(stamp, dict) else 0 + total = inp + output + reasoning + cache_write + return { + "session_id": session_id, + "model_id": model_id, + "provider_id": provider_id, + "input": inp, + "output": output, + "reasoning": reasoning, + "cache_read": cache_read, + "cache_write": cache_write, + "total": total, + "total_with_cache_read": total + cache_read, + "cost": as_float(data.get("cost")), + "time": moment or as_int(fallback_ms), + } -def _table_columns(con, table: str) -> list[str]: +def _table_columns(con: sqlite3.Connection, table: str) -> set[str]: + """Column names of a sqlite table, empty when it does not exist.""" try: - return [r[1] for r in con.execute(f"pragma table_info({table})").fetchall()] + rows = con.execute(f"pragma table_info({table})").fetchall() except sqlite3.Error: + return set() + return {str(row[1]) for row in rows} + + +def load_session_messages(cutoff_ms: int) -> list[dict]: + """Per-message usage from the v2 store, oldest first. + + Unlike the legacy `message` table, this one keeps every assistant message + of every v2 session, including OpenChamber's `opencode serve` sessions. + Column names drifted across OpenCode versions (role/type, data/content, + time_created/created_at), so select through whichever ones exist. + """ + if not DB_FILE.exists(): return [] + try: + con = _connect_ro() + except sqlite3.Error: + return [] + out: list[dict] = [] + try: + columns = _table_columns(con, "session_message") + if not columns: + return [] + sid = "session_id" if "session_id" in columns else "session" + kind = "type" if "type" in columns else "role" + blob = "data" if "data" in columns else "content" + stamped = next((name for name in + ("time_created", "created_at", "time_updated") + if name in columns), "") + if not {sid, kind, blob} <= columns: + return [] + stamp = stamped or "0" + with con: + rows = con.execute( + f"select {sid}, {kind}, {stamp}, {blob} " + "from session_message " + f"where {kind} in ('assistant', 'compaction') " + + (f"and {stamp} >= ? " if stamped else "") + + f"order by {stamp} asc", + (cutoff_ms,) if stamped else (), + ).fetchall() + for session_id, row_kind, row_time, raw in rows: + try: + data = json.loads(raw or "{}") + except ValueError: + continue + rec = parse_v2_message(str(row_kind), data, str(session_id or ""), as_int(row_time)) + if rec is not None: + out.append(rec) + except sqlite3.Error: + return [] + finally: + con.close() + return out -def _pick(columns: list[str], *candidates: str) -> str | None: - lowered = {c.lower(): c for c in columns} - for cand in candidates: - if cand in lowered: - return lowered[cand] - return None +def balance_session(records: list[dict], agg: dict) -> None: + """True a session up to its aggregate without touching old history. + The aggregate is authoritative for the session total, but it is a lifetime + tally: it cannot say which day a token landed on. Scaling every message to + match it rewrote past days on every run, so a session whose aggregate moved + (a lagging write, a compaction recount) made old days shrink and grow while + it was still in use. -def load_v2_messages(con, cutoff_ms: int) -> list[dict]: - """2.x layout: opencode.db's session_message table, where each assistant - turn is a row whose JSON blob holds tokens/model/cost and the role lives in - a type column. Column names drift across releases, so the blob, role and - time columns are detected from the live schema rather than assumed.""" - if "session_message" not in [ - r[0] for r in con.execute("select name from sqlite_master where type='table'") - ]: + Instead, only the positive residual (tokens the message store is missing) + is added, once, to the session's newest message. Existing records are never + reduced or rescaled, so a day that has been counted keeps its tokens. + """ + if not records: + return + latest = max(range(len(records)), key=lambda index: records[index].get("time", 0)) + fields = ("input", "output", "reasoning", "cache_read", "cache_write") + for field in fields: + base = sum(int(row.get(field, 0)) for row in records) + target = as_int(agg.get(field)) + if target > base: + records[latest][field] = int(records[latest].get(field, 0)) + (target - base) + base_cost = sum(float(row.get("cost", 0.0)) for row in records) + target_cost = as_float(agg.get("cost")) + if target_cost > base_cost: + records[latest]["cost"] = float(records[latest].get("cost", 0.0)) + (target_cost - base_cost) + for row in records: + row["total"] = int(row["input"]) + int(row["output"]) + int(row["reasoning"]) + int(row["cache_write"]) + row["total_with_cache_read"] = row["total"] + int(row["cache_read"]) + + +def load_session_v2_aggregates(cutoff_ms: int, skip_session_ids: set | None = None) -> list[dict]: + """Per-session totals from session_v2. + + Used for a session with no message rows at all, and to true up a session + whose message rows are missing a request. `time_updated` is only the + record's time when there are no messages; it can also be a migration touch. + """ + skip_session_ids = skip_session_ids or set() + if not DB_FILE.exists(): return [] - columns = _table_columns(con, "session_message") - blob_col = _pick(columns, "data", "content", "body", "message", "json") - role_col = _pick(columns, "type", "role") - session_col = _pick(columns, "session_id", "sessionid", "session") - time_col = _pick(columns, "time_updated", "updated_at", "time_created", "created_at") - if not blob_col: + try: + con = _connect_ro() + except sqlite3.Error: + return [] + out: list[dict] = [] + try: + with con: + try: + rows = con.execute( + "select id, model, tokens_input, tokens_output, " + "tokens_reasoning, tokens_cache_read, tokens_cache_write, " + "cost, time_updated " + "from session_v2 where time_updated >= ? " + "order by time_updated asc", + (cutoff_ms,), + ).fetchall() + except sqlite3.Error: + return [] + for sid, model_raw, inp, output, reasoning, cr, cw, cost, tu in rows: + if str(sid or "") in skip_session_ids: + continue + try: + m = json.loads(model_raw or "{}") + except ValueError: + m = {} + model_id = str(m.get("id") or "") + if not model_id: + continue + provider_id = str(m.get("providerID") or "") + inp, output, reasoning = as_int(inp), as_int(output), as_int(reasoning) + cr, cw = as_int(cr), as_int(cw) + total = inp + output + reasoning + cw + if total <= 0: + continue + out.append({ + "session_id": str(sid), + "model_id": model_id, + "provider_id": provider_id, + "input": inp, + "output": output, + "reasoning": reasoning, + "cache_read": cr, + "cache_write": cw, + "total": total, + "total_with_cache_read": total + cr, + "cost": as_float(cost), + "time": as_int(tu), + }) + except sqlite3.Error: + return [] + finally: + con.close() + return out + + +def load_sessions(limit: int = 10) -> list[dict]: + """The latest sessions, for the Now card's live rows. + + `running` is the store's own truth: a session still runs while its last + busy stamp is newer than its idle stamp, so no timer guess is made here. + """ + if not DB_FILE.exists(): + return [] + try: + con = _connect_ro() + except sqlite3.Error: return [] - select_cols = [blob_col] - for c in (role_col, session_col, time_col): - if c and c != blob_col: - select_cols.append(c) - where = "" - params: tuple = () - if time_col: - where = f" where {time_col} >= ?" - params = (cutoff_ms,) try: with con: rows = con.execute( - f"select {', '.join(select_cols)} from session_message{where}", params + "select id, title, directory, time_created, time_updated, " + "time_idle, time_busy, tokens_input, tokens_output, " + "tokens_reasoning, tokens_cache_write, cost " + "from session_v2 order by time_updated desc limit ?", + (as_int(limit) or 10,), ).fetchall() except sqlite3.Error: return [] + finally: + con.close() out: list[dict] = [] - for row in rows: - values = dict(zip(select_cols, row)) - try: - blob = json.loads(values.get(blob_col) or "{}") - except ValueError: - continue - rec = parse_message_v2(blob, str(values.get(role_col, "")), str(values.get(session_col, ""))) - if rec is None: - continue - if not rec["time"]: - rec["time"] = as_int(values.get(time_col)) - out.append(rec) + for sid, title, directory, created, updated, idle, busy, inp, output, reasoning, cw, cost in rows: + out.append({ + "id": str(sid or ""), + "title": str(title or ""), + "directory": str(directory or ""), + "started": as_int(created), + "updated": as_int(updated), + "tokens": as_int(inp) + as_int(output) + as_int(reasoning) + as_int(cw), + "cost": round(as_float(cost), 4), + "running": as_int(busy) > as_int(idle), + }) return out -def load_messages() -> list[dict]: - cutoff_s = time.time() - WINDOW_HORIZON_S +def load_messages(horizon_s: float = WINDOW_HORIZON_S) -> list[dict]: + cutoff_s = time.time() - horizon_s cutoff_ms = int(cutoff_s * 1000) - msgs = load_fs_messages(cutoff_s) - if not msgs and DB_FILE.exists(): - try: - con = sqlite3.connect(f"file:{DB_FILE}?mode=ro", uri=True, timeout=2.0) - con.execute("pragma busy_timeout=2000") - except sqlite3.Error: - con = None - if con is not None: - try: - msgs = load_v2_messages(con, cutoff_ms) - finally: - con.close() + msgs = load_session_messages(cutoff_ms) + per_message_v2 = bool(msgs) + if not msgs: + msgs = load_fs_messages(cutoff_s) if not msgs: msgs = load_sqlite_messages(cutoff_ms) + covered = {str(m.get("session_id") or "") for m in msgs if m.get("session_id")} + aggs = load_session_v2_aggregates(cutoff_ms) + by_session: dict[str, list[dict]] = {} + for msg in msgs: + sid = str(msg.get("session_id") or "") + if sid: + by_session.setdefault(sid, []).append(msg) + for agg in aggs: + sid = str(agg.get("session_id") or "") + rows = by_session.get(sid) if per_message_v2 else None + if rows: + balance_session(rows, agg) + elif sid not in covered: + msgs.append(agg) msgs.sort(key=lambda m: m["time"]) return msgs @@ -371,8 +546,122 @@ def pct(tokens: int, limit: int) -> float: return min(1.0, max(0.0, tokens / float(limit))) +def start_of_day(days_ago: int) -> int: + day = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days_ago) + return int(day.timestamp() * 1000) + + +def hour_start_ms(ts_ms: int) -> int: + moment = datetime.fromtimestamp(ts_ms / 1000).replace(minute=0, second=0, microsecond=0) + return int(moment.timestamp() * 1000) + + +def hour_history(msgs: list[dict], hours: int = 48) -> list[dict]: + """One bucket per clock hour, oldest first, the current hour last. + + Today's view and the rolling 24-hour view both slice this list, so their + bars and their totals come from the same buckets. + """ + current = hour_start_ms(int(time.time() * 1000)) + start = current - (hours - 1) * 3600 * 1000 + by_hour: dict[int, int] = {} + cache_by_hour: dict[int, int] = {} + for msg in msgs: + if msg["time"] < start: + continue + key = hour_start_ms(msg["time"]) + by_hour[key] = by_hour.get(key, 0) + msg["total"] + cache_by_hour[key] = cache_by_hour.get(key, 0) + msg["cache_read"] + out = [] + for i in range(hours - 1, -1, -1): + key = current - i * 3600 * 1000 + out.append({ + "start": key, + "tokens": int(by_hour.get(key, 0)), + "cacheRead": int(cache_by_hour.get(key, 0)), + }) + return out + + +def period_stats(msgs: list[dict], since_ms: int, limit: int = 8) -> dict: + """Tokens, cache share, cost and per-model rows for one window. + + The module's period control reads these three windows (7 days, 30 days, + all time), so the numbers on every card come from the same slice. + """ + by_model: dict = {} + tokens = 0 + cache_read = 0 + with_cache = 0 + cost = 0.0 + sessions: set = set() + for msg in msgs: + if msg["time"] < since_ms: + continue + key = (msg["provider_id"], msg["model_id"]) + cur = by_model.setdefault(key, { + "name": model_label(msg["model_id"], msg["provider_id"]), + "input": 0, "output": 0, "reasoning": 0, + "cache_read": 0, "cache_write": 0, + "total": 0, "cost": 0.0, "last_seen": 0, + "sessions": set(), + }) + cur["input"] += msg["input"] + cur["output"] += msg["output"] + cur["reasoning"] += msg["reasoning"] + cur["cache_read"] += msg["cache_read"] + cur["cache_write"] += msg["cache_write"] + cur["total"] += msg["total"] + cur["cost"] += msg["cost"] + cur["last_seen"] = max(cur["last_seen"], msg["time"]) + if msg["session_id"]: + cur["sessions"].add(msg["session_id"]) + sessions.add(msg["session_id"]) + tokens += msg["total"] + cache_read += msg["cache_read"] + with_cache += msg["total_with_cache_read"] + cost += msg["cost"] + + # Rank and scale by everything the model spent, cache reads included. + def weight(row): + return row["total"] + row["cache_read"] + + rows = sorted(by_model.values(), key=weight, reverse=True)[:limit] + peak = max((weight(row) for row in rows), default=0) + models = [] + for row in rows: + total = int(row["total"]) + cache_total = total + int(row["cache_read"]) + models.append({ + "name": row["name"], + "total": total, + "totalLabel": fmt_tokens(total), + "totalWithCacheRead": cache_total, + "totalWithCacheReadLabel": fmt_tokens(cache_total), + "inputLabel": fmt_tokens(int(row["input"])), + "outputLabel": fmt_tokens(int(row["output"])), + "reasoningLabel": fmt_tokens(int(row["reasoning"])), + "cacheReadLabel": fmt_tokens(int(row["cache_read"])), + "cacheWriteLabel": fmt_tokens(int(row["cache_write"])), + "cacheRate": round(row["cache_read"] / cache_total, 4) if cache_total else 0.0, + "cost": round(float(row["cost"]), 4), + "costLabel": f"${row['cost']:.2f}" if row["cost"] > 0.0001 else "", + "sessions": len(row["sessions"]), + "lastSeen": int(row["last_seen"]), + "pct": int(round(cache_total * 100 / peak)) if peak else 0, + }) + return { + "tokens": int(tokens), + "tokensLabel": fmt_tokens(int(tokens)), + "cacheRate": round(cache_read / with_cache, 4) if with_cache else 0.0, + "cost": round(float(cost), 4), + "costLabel": f"${cost:.2f}" if cost > 0.0005 else "", + "sessions": len(sessions), + "models": models, + } + + def recent_days(msgs: list[dict], days: int = 7) -> list[dict]: - """Per-day assistant-token totals for the last `days` days, oldest->newest.""" now = datetime.now().astimezone() start = now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=days - 1) start_ms = int(start.timestamp() * 1000) @@ -390,17 +679,158 @@ def recent_days(msgs: list[dict], days: int = 7) -> list[dict]: return out +def day_history(msgs: list[dict]) -> list[dict]: + """One bucket per day from the first recorded message to today. + + Every v2 assistant message and compaction call carries its own timestamp, + so a session that spans days is split across the days it actually ran. + Session aggregates only fill a session that has no message rows at all. + """ + if not msgs: + return [] + now = datetime.now().astimezone() + today = now.date() + first = datetime.fromtimestamp(min(m["time"] for m in msgs) / 1000).date() + span = max(1, min((today - first).days + 1, HISTORY_DAYS)) + start = today - timedelta(days=span - 1) + start_ms = int(datetime.combine(start, datetime.min.time()).astimezone().timestamp() * 1000) + by_day: dict[str, int] = {} + cache_by_day: dict[str, int] = {} + cost_by_day: dict[str, float] = {} + for msg in msgs: + if msg["time"] < start_ms: + continue + key = datetime.fromtimestamp(msg["time"] / 1000).strftime("%Y-%m-%d") + by_day[key] = by_day.get(key, 0) + msg["total"] + cache_by_day[key] = cache_by_day.get(key, 0) + msg["cache_read"] + cost_by_day[key] = cost_by_day.get(key, 0.0) + float(msg.get("cost", 0.0)) + out = [] + for i in range(span - 1, -1, -1): + key = (today - timedelta(days=i)).strftime("%Y-%m-%d") + out.append({ + "date": key, + "tokens": int(by_day.get(key, 0)), + "cacheRead": int(cache_by_day.get(key, 0)), + "cost": round(cost_by_day.get(key, 0.0), 6), + }) + return out + + +def merge_day_history(days: list[dict], previous: dict) -> list[dict]: + """Keep published day totals monotonic. + + A session aggregate can be rewritten (compaction recount, migration) or a + residual can move to a newer message. Either way, a day that was already + shown must not lose tokens: history is append-only. Merge the new buckets + with the previous cache by date, keeping the larger value for each field. + """ + if not isinstance(previous, dict): + return days + old_days = previous.get("_days") + if previous.get("_source") not in ("storage", "storage+v2") or not isinstance(old_days, list): + return days + by_date: dict[str, dict] = {str(d["date"]): d for d in days} + for old in old_days: + date = str(old.get("date") or "") + if not date: + continue + current = by_date.get(date) + if current is None: + by_date[date] = { + "date": date, + "tokens": as_int(old.get("tokens")), + "cacheRead": as_int(old.get("cacheRead")), + "cost": as_float(old.get("cost")), + } + else: + current["tokens"] = max(as_int(current.get("tokens")), + as_int(old.get("tokens"))) + current["cacheRead"] = max(as_int(current.get("cacheRead")), + as_int(old.get("cacheRead"))) + current["cost"] = max(as_float(current.get("cost")), + as_float(old.get("cost"))) + return sorted(by_date.values(), key=lambda day: str(day["date"])) + + +def merge_hour_history(hours: list[dict], previous: dict) -> list[dict]: + """Same append-only rule for the rolling 48-hour buckets. + + Only buckets that are still inside the new window are merged, so the list + keeps its 48-hour length; an hour already shown never loses tokens. + """ + if not isinstance(previous, dict) or not hours: + return hours + old_hours = previous.get("_hours") + if previous.get("_source") not in ("storage", "storage+v2") or not isinstance(old_hours, list): + return hours + first = as_int(hours[0].get("start")) + last = as_int(hours[-1].get("start")) + by_start: dict[int, dict] = {as_int(hour.get("start")): hour for hour in hours} + for old in old_hours: + start = as_int(old.get("start")) + if start < first or start > last: + continue + current = by_start.get(start) + if current is None: + by_start[start] = { + "start": start, + "tokens": as_int(old.get("tokens")), + "cacheRead": as_int(old.get("cacheRead")), + } + else: + current["tokens"] = max(as_int(current.get("tokens")), + as_int(old.get("tokens"))) + current["cacheRead"] = max(as_int(current.get("cacheRead")), + as_int(old.get("cacheRead"))) + return sorted(by_start.values(), key=lambda hour: as_int(hour["start"])) + + +def previous_cache() -> dict: + try: + return json.loads(CACHE_FILE.read_text()) + except (OSError, ValueError): + return {} + + +def acquire_lock(): + """One collector at a time. A manual refresh can race the timer; both read + the previous cache to keep the day history append-only, so they must not + write it concurrently.""" + lock_path = CACHE_FILE.with_name(CACHE_FILE.name + ".lock") + try: + lock_path.parent.mkdir(parents=True, exist_ok=True) + handle = lock_path.open("w") + fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + return handle + except OSError: + return None + + def main() -> None: + lock = acquire_lock() + if lock is None: + return + try: + _run() + finally: + lock.close() + + +def _run() -> None: if not STORAGE_DIR.is_dir() and not DB_FILE.exists(): stale_last() return - msgs = load_messages() - if not msgs: + now = time.time() + # Read the long history once; the shared counters keep their 8-day window + # so the shell's chips read exactly what they read before. + msgs_all = load_messages(HISTORY_DAYS * 24 * 3600) + if not msgs_all: stale_last() return + window_ms = int((now - WINDOW_HORIZON_S) * 1000) + msgs = [msg for msg in msgs_all if msg["time"] >= window_ms] - now = time.time() five_h_ms = int((now - 5 * 3600) * 1000) seven_d_ms = int((now - 7 * 24 * 3600) * 1000) hour_ms = int((now - 3600) * 1000) @@ -414,6 +844,10 @@ def main() -> None: model = newest_model(msgs) models = model_rows(msgs, today_ms) recent_days_list = recent_days(msgs) + # Read the published history before writing so a day never decreases. + published = previous_cache() + days = merge_day_history(day_history(msgs_all), published) + hours = merge_hour_history(hour_history(msgs_all), published) out = { "5h-utilization": f"{pct(tok_5h, LIMIT_5H):.4f}", @@ -433,6 +867,17 @@ def main() -> None: "_today_tokens": tok_today, "_today_tokens_with_cache_read": tok_today_with_cache, "_recent_days": recent_days_list, + "_days": days, + "_hours": hours, + "_sessions": load_sessions(), + "_periods": { + "today": period_stats(msgs_all, start_of_day(0)), + "24h": period_stats(msgs_all, + hour_start_ms(int(now * 1000)) - 23 * 3600 * 1000), + "7": period_stats(msgs_all, start_of_day(6)), + "30": period_stats(msgs_all, start_of_day(29)), + "all": period_stats(msgs_all, 0), + }, "_7d_tokens": tok_7d, "_7d_tokens_with_cache_read": tok_7d_with_cache, "_7d_limit": LIMIT_7D, @@ -442,8 +887,27 @@ def main() -> None: "status": "allowed_warning" if tok_5h >= LIMIT_5H * 0.9 else "allowed", } CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) - CACHE_FILE.write_text(json.dumps(out)) + # Atomic publish: readers (the island, the shell panel) must never see a + # half-written cache, and the next run merges the previous day history. + temp_file = CACHE_FILE.with_name(CACHE_FILE.name + ".tmp") + temp_file.write_text(json.dumps(out)) + os.replace(temp_file, CACHE_FILE) + + +def cli() -> None: + global CACHE_FILE + parser = argparse.ArgumentParser( + prog="opencode-usage", + description="Write the shared OpenCode usage cache from the local session store.") + parser.add_argument("--version", action="version", + version="opencode-usage " + VERSION) + parser.add_argument("--cache", type=Path, + help="write this cache path instead of ~/.cache/opencode-usage.json") + args = parser.parse_args() + if args.cache is not None: + CACHE_FILE = args.cache.expanduser() + main() if __name__ == "__main__": - main() + cli() diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml new file mode 100644 index 000000000..6bf197406 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml @@ -0,0 +1,94 @@ +pragma ComponentBehavior: Bound + +// AI usage desktop tile — the quiet wallpaper face of the same module data. +// The bar face lives in IrisCompact.qml; both read UsageModel. +import QtQuick +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.background.widgets + +AbstractBackgroundWidget { + id: root + + configEntryName: "custom.ai-usage" + + readonly property string tool: CustomWidgets.getConfigValue("ai-usage", "tool", "opencode") + readonly property var labels: ({ opencode: "OpenCode", claude: "Claude", codex: "Codex" }) + readonly property string toolLabel: root.labels[root.tool] ?? root.tool + + implicitWidth: Math.round(172 * scaleFactor) + implicitHeight: Math.round(64 * scaleFactor) + resizableAxes: ({ uniform: "widgetScale" }) + resizeMinWidth: 120 + resizeMinHeight: 48 + + UsageModel { + id: usage + tool: root.tool + } + + Rectangle { + anchors.fill: parent + radius: root.cornerRadiusOverride >= 0 ? root.cornerRadiusOverride : Appearance.rounding.normal + color: root.backgroundOpacity > 0 ? ColorUtils.applyAlpha(root.colText, root.backgroundOpacity) : "transparent" + border { + width: root.borderWidth + color: ColorUtils.applyAlpha(root.colText, root.borderOpacity) + } + } + + Column { + id: content + spacing: Math.round(4 * root.scaleFactor) + anchors.centerIn: parent + + Row { + spacing: Math.round(6 * root.scaleFactor) + anchors.horizontalCenter: parent.horizontalCenter + + MaterialSymbol { + text: "neurology" + iconSize: Math.round(16 * root.scaleFactor) + color: root.colText + anchors.verticalCenter: parent.verticalCenter + } + StyledText { + text: root.toolLabel + font.pixelSize: Math.round(Appearance.font.pixelSize.small * root.scaleFactor) + color: ColorUtils.applyAlpha(root.colText, 0.65) + anchors.verticalCenter: parent.verticalCenter + } + } + + StyledText { + text: !usage.has ? "—" + : usage.hasPlan && usage.windows.length > 0 + ? usage.windows[0].pct + "% · " + usage.windows[0].label + : usage.kmg(usage.today) + " tokens today" + font { + pixelSize: Math.round(Appearance.font.pixelSize.large * root.scaleFactor) + family: Appearance.font.family.numbers + } + color: root.colText + anchors.horizontalCenter: parent.horizontalCenter + } + + Rectangle { + width: parent.width + anchors.horizontalCenter: content.horizontalCenter + height: Math.round(4 * root.scaleFactor) + radius: height / 2 + color: ColorUtils.applyAlpha(root.colText, 0.15) + visible: usage.has && usage.hasPlan && usage.windows.length > 0 + Rectangle { + width: parent.width * (usage.windows[0]?.pct ?? 0) / 100 + height: parent.height + radius: parent.radius + color: root.colText + } + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml new file mode 100644 index 000000000..0faeec07f --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml @@ -0,0 +1,499 @@ +pragma ComponentBehavior: Bound + +// AI usage — the Shima Island's AI page, laid out like Vorssaint's Dynamic +// Island page: a grid of small cards. +// +// [ Limits | Now ] ← Limits only when the cache reports a real plan +// [ Trend ] +// [ Models ] +// +// Every card is an IrisSurface on Shima tokens; the data is the shared usage +// cache (~/.cache/-usage.json) and nothing here writes usage anywhere. +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components +import "components" + +Item { + id: root + + property string irisSlot: "" + property var targetScreen + + // ── config (written by Shima Settings; read null-guarded here) ── + readonly property string tool: CustomWidgets.getConfigValue("ai-usage", "tool", "opencode") + readonly property var labels: ({ opencode: "OpenCode", claude: "Claude", codex: "Codex" }) + + // ── agents this machine actually uses ── + // A usage cache exists only after that agent ran here, so the menu offers + // just the agents with a cache. The stored tool stays valid: if its cache + // disappears the page falls back to the first agent that still has one + // (OpenCode first) and writes that once. + // Claude is held back until its collector publishes the same history the + // other two do; it returns to the catalogue with that follow-up. + readonly property var agentCatalog: [ + { value: "opencode", label: "OpenCode", available: availability.opencode }, + { value: "codex", label: "Codex", available: availability.codex } + ] + readonly property var agentOptions: root.agentCatalog.filter(option => option.available) + readonly property bool hasAnyAgent: root.agentOptions.length > 0 + readonly property bool toolAvailable: root.agentCatalog.some( + option => option.value === root.tool && option.available) + // One agent means nothing to switch to: the chip stays, the chevron goes. + readonly property bool agentSwitchable: availability.ready && root.agentOptions.length > 1 + // No cache at all: keep the card and name OpenCode, like a fresh install, + // instead of an empty menu and a chip with nothing behind it. A stored + // tool this build does not offer (Claude while it is held back) falls to + // the first agent that has a cache, without waiting for the write back. + readonly property string activeTool: { + if (!availability.ready) return root.tool + if (!root.hasAnyAgent) return "opencode" + if (root.toolAvailable) return root.tool + return String(root.agentOptions[0]?.value ?? "opencode") + } + readonly property string toolLabel: root.labels[root.activeTool] ?? root.activeTool + property bool _fallbackWritten: false + + // The Island's Desktop page body is iris.bar.pageWidth minus its 20px + // padding each side; a wider card would overflow the chassis and clip. + readonly property int pageBody: Math.max(280, + Math.max(360, Math.min(600, Number(Config.options?.iris?.bar?.pageWidth ?? 440))) + - 2 * Math.round(20 * IrisStyle.density)) + readonly property int cardWidth: Math.max(280, Math.min(root.pageBody, + Number(CustomWidgets.getConfigValue("ai-usage", "moduleWidth", 480)))) + readonly property int topModels: Math.max(1, Math.min(3, + Number(CustomWidgets.getConfigValue("ai-usage", "topModels", 3)))) + + readonly property var periodOptions: [ + { value: "today", label: "Today" }, + { value: "24h", label: "Last 24 hours" }, + { value: "7", label: "Last 7 days" }, + { value: "30", label: "Last 30 days" }, + { value: "all", label: "All time" } + ] + readonly property string periodKey: { + const stored = String(CustomWidgets.getConfigValue("ai-usage", "period", "30")) + return ["today", "24h", "7", "30", "all"].includes(stored) ? stored : "30" + } + readonly property bool hourly: root.periodKey === "today" || root.periodKey === "24h" + readonly property int periodDays: root.periodKey === "all" ? 0 : Number(root.periodKey) + readonly property string periodLabel: { + const option = root.periodOptions.find(entry => entry.value === root.periodKey) + return option ? option.label : "Last 30 days" + } + + readonly property int pad: Math.round(10 * IrisStyle.density) + readonly property int gap: Math.round(10 * IrisStyle.density) + readonly property int radius: IrisStyle.radiusTile + readonly property int step: Math.round(2 * IrisStyle.density) + + implicitWidth: root.cardWidth + implicitHeight: grid.implicitHeight + + AgentAvailability { + id: availability + } + + UsageModel { + id: usageModel + tool: root.activeTool + } + + // Everything on the page follows this one period. + readonly property var activePeriod: usageModel.period(root.periodKey) + readonly property var periodModels: root.activePeriod ? root.activePeriod.models : [] + // This month's spend from the day buckets (cost is per assistant turn). + readonly property var monthly: { + const key = Qt.formatDate(new Date(), "yyyy-MM") + if (root.activeTool === "codex") { + const estimate = usageModel.spending + const available = estimate?.kind === "api-equivalent" && estimate?.month === key + && estimate?.amount !== null && estimate?.amount !== undefined + && Number.isFinite(Number(estimate.amount)) && Number(estimate.amount) >= 0 + return ({ current: available ? Number(estimate.amount) : 0, + label: root.monthLabel(key), available: available, + partial: estimate?.partial === true, stale: estimate?.stale_prices === true }) + } + let total = 0 + for (const day of usageModel.days) { + if (String(day?.date ?? "").slice(0, 7) === key) + total += Number(day?.cost ?? 0) + } + return ({ current: total, label: root.monthLabel(key) }) + } + readonly property string spendHelp: { + if (root.activeTool !== "codex") return "This month's recorded usage cost." + const estimate = usageModel.spending + let text = "Estimated cost at standard API rates.\nNot a bill or wallet balance." + if (usageModel.plan.toLowerCase() === "plus") + text += "\nYour Plus usage is covered by your plan." + if (root.monthly.partial) text += "\nOnly days with saved prices are included." + if (estimate?.price_date) text += "\nLatest saved prices: " + estimate.price_date + "." + if (root.monthly.stale) text += "\nPrice refresh pending; saved rates are in use." + if (!root.monthly.available) text += "\nNo priced usage is available yet." + return text + } + readonly property bool hasCostHistory: usageModel.days.some(day => Number(day?.cost ?? 0) > 0) + readonly property real periodCost: root.activePeriod + ? Number(root.activePeriod.cost) : 0 + readonly property bool hasSpend: root.periodCost > 0.0005 || root.hasCostHistory + // Claude and Codex are plan agents: Spending shares their Now card. OpenCode + // keeps the standalone Spend card beside Now. + readonly property bool planAgent: root.activeTool !== "opencode" + readonly property bool integratedSpend: root.planAgent + readonly property bool hasPlan: usageModel.hasPlan + + // Keep the stored agent pointing at a cache that exists. One write per + // stale value; changing the tool re-arms it, so a later loss falls back + // again. Nothing to fall back to leaves the config alone. + function reconcileAgent(): void { + if (!availability.ready) + return + if (!root.agentSwitchable) + agentMenu.open = false + if (root.toolAvailable || !root.hasAnyAgent || root._fallbackWritten) + return + root._fallbackWritten = true + CustomWidgets.setConfigValue("ai-usage", "tool", + String(root.agentOptions[0]?.value ?? "opencode")) + } + onToolChanged: root._fallbackWritten = false + Connections { + target: availability + function onOpencodeChanged() { root.reconcileAgent() } + function onClaudeChanged() { root.reconcileAgent() } + function onCodexChanged() { root.reconcileAgent() } + function onReadyChanged() { root.reconcileAgent() } + } + + // Layout 1 was the single long card, layout 2 the fixed 60/90/180 control. + // Run once, then the stored values are the person's own. + Component.onCompleted: { + const version = Number(CustomWidgets.getConfigValue("ai-usage", "layoutVersion", 1)) + if (version < 2) { + CustomWidgets.setConfigValue("ai-usage", "moduleWidth", 480) + CustomWidgets.setConfigValue("ai-usage", "topModels", 3) + } + if (version < 3) { + const stored = String(CustomWidgets.getConfigValue("ai-usage", "period", "30")) + if (!["7", "30", "all"].includes(stored)) + CustomWidgets.setConfigValue("ai-usage", "period", "30") + CustomWidgets.setConfigValue("ai-usage", "layoutVersion", 3) + } + root.reconcileAgent() + } + + // ── trend buckets: hourly for Today / 24 hours, daily for the rest ── + readonly property var trend: { + if (root.hourly) { + const allHours = usageModel.hours + if (!allHours || allHours.length === 0) + return ({ buckets: [], total: 0, peak: 1, span: 0, unit: "hours" }) + const hourBuckets = [] + let hourTotal = 0 + let hourPeak = 1 + if (root.periodKey === "today") { + // The whole day as 24 slots, so the shape of the day is the + // same at 02:00 and at 22:00; hours still to come stay empty. + const midnight = new Date() + midnight.setHours(0, 0, 0, 0) + const byStart = ({}) + for (const hour of allHours) + byStart[Number(hour?.start ?? 0)] = root.historyWeight(hour) + for (let i = 0; i < 24; i++) { + const start = midnight.getTime() + i * 3600000 + const tokens = Number(byStart[start] ?? 0) + hourBuckets.push({ start: start, tokens: tokens }) + hourTotal += tokens + hourPeak = Math.max(hourPeak, tokens) + } + return ({ buckets: hourBuckets, total: hourTotal, peak: hourPeak, + span: 24, unit: "hours" }) + } + const list = allHours.slice(Math.max(0, allHours.length - 24)) + for (const hour of list) { + const tokens = root.historyWeight(hour) + hourBuckets.push({ start: Number(hour?.start ?? 0), tokens: tokens }) + hourTotal += tokens + hourPeak = Math.max(hourPeak, tokens) + } + return ({ buckets: hourBuckets, total: hourTotal, peak: hourPeak, + span: list.length, unit: "hours" }) + } + const all = usageModel.days + if (!all || all.length === 0) + return ({ buckets: [], total: 0, peak: 1, span: 0, unit: "days" }) + // Fixed calendar slots ending today: a period shows its whole window + // even when the cache starts a day or two ago, like the hourly view. + const byDate = ({}) + for (const day of all) { + const key = String(day?.date ?? "") + if (isNaN(new Date(key + "T12:00:00").getTime())) continue + byDate[key] = root.historyWeight(day) + } + const today = new Date() + today.setHours(12, 0, 0, 0) + // Periods end today. All time is always the same 45-day window: a + // short cache draws dim empty days instead of two giant bars, and a + // long one never turns the chart into hairlines. + const count = root.periodDays > 0 ? root.periodDays : 45 + const list = [] + for (let step = count - 1; step >= 0; step--) { + const slot = new Date(today.getTime()) + slot.setDate(slot.getDate() - step) + const key = Qt.formatDate(slot, "yyyy-MM-dd") + list.push({ date: key, tokens: Number(byDate[key] ?? 0) }) + } + const buckets = [] + let total = 0 + let peak = 1 + for (const day of list) { + const tokens = root.historyWeight(day) + buckets.push({ date: String(day?.date ?? ""), tokens: tokens }) + total += tokens + peak = Math.max(peak, tokens) + } + return ({ buckets: buckets, total: total, peak: peak, span: list.length, unit: "days" }) + } + readonly property var trendBuckets: root.trend.buckets + + // Every chart weighs tokens plus cache reads, like the Activity card. + function historyWeight(entry) { + return Number(entry?.tokens ?? 0) + Number(entry?.cacheRead ?? 0) + } + + function dayLabel(date: string): string { + const parsed = new Date(String(date) + "T12:00:00") + if (isNaN(parsed.getTime())) return "" + return Qt.formatDate(parsed, "d MMM") + } + function bucketLabel(bucket: var): string { + if (root.trend.unit === "hours") { + const moment = new Date(Number(bucket?.start ?? 0)) + return isNaN(moment.getTime()) ? "" : Qt.formatTime(moment, "HH:mm") + } + return root.dayLabel(String(bucket?.date ?? "")) + } + function money(value: real): string { + return "$" + Number(value || 0).toFixed(2) + } + function monthLabel(key: string): string { + const parsed = new Date(String(key) + "-01T12:00:00") + return isNaN(parsed.getTime()) ? String(key) : Qt.formatDate(parsed, "MMM") + } + function elapsedLabel(startMs: double): string { + usageModel.tick // a binding that calls this re-evaluates when the ticker moves + const mins = Math.max(0, Math.floor((Date.now() - Number(startMs || 0)) / 60000)) + if (mins < 60) return mins + "m" + const m = mins % 60 + return Math.floor(mins / 60) + "h " + (m < 10 ? "0" : "") + m + "m" + } + function modelDetail(model: var): string { + if (!model) return "" + const parts = ["I " + (model.inputLabel || "0"), "O " + (model.outputLabel || "0")] + if (String(model.reasoningLabel ?? "0") !== "0") parts.push("R " + model.reasoningLabel) + if (String(model.cacheReadLabel ?? "0") !== "0") parts.push("CR " + model.cacheReadLabel) + if (String(model.cacheWriteLabel ?? "0") !== "0") parts.push("CW " + model.cacheWriteLabel) + let text = parts.join(" ") + if (model.cacheRate !== undefined && model.cacheRate !== null) + text += " · " + Math.round(Number(model.cacheRate) * 100) + "% cached" + return text + } + + // The period menu sits above the grid so no card clips it. + function openPeriodMenu(trigger: Item): void { + agentMenu.open = false + const point = trigger.mapToItem(root, 0, trigger.height) + periodMenu.x = Math.max(0, Math.min(root.cardWidth - periodMenu.width, + Math.round(point.x + (trigger.width - periodMenu.width) / 2))) + periodMenu.y = Math.max(0, Math.min(Math.round(point.y + 4 * IrisStyle.density), + Math.round(root.implicitHeight - periodMenu.height - 2))) + periodMenu.open = true + } + + // The agent list drops straight out of its chip, near the card's top. + function openAgentMenu(): void { + if (!root.agentSwitchable) + return + periodMenu.open = false + const point = agentChip.mapToItem(root, 0, agentChip.height) + agentMenu.x = Math.max(0, Math.min(root.cardWidth - agentMenu.width, Math.round(point.x))) + agentMenu.y = Math.round(point.y + 4 * IrisStyle.density) + agentMenu.open = true + } + + Column { + id: grid + width: root.cardWidth + spacing: root.gap + + // The agent list opens from this chip: one cache is read at a time, + // and a pick writes the same `tool` key Settings writes. With a single + // agent there is nothing to switch to, so the chip is just a label. + Item { + id: agentBar + width: parent.width + height: Math.round(28 * IrisStyle.density) + + Rectangle { + id: agentChip + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + width: agentChipRow.implicitWidth + Math.round(20 * IrisStyle.density) + height: Math.round(28 * IrisStyle.density) + radius: height / 2 + color: agentMenu.open ? IrisStyle.fillHover : IrisStyle.fillQuiet + border.width: 1 + border.color: ColorUtils.applyAlpha(IrisStyle.text, 0.10) + + Row { + id: agentChipRow + anchors.centerIn: parent + spacing: Math.round(5 * IrisStyle.density) + IrisText { + anchors.verticalCenter: parent.verticalCenter + text: root.toolLabel + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + MaterialSymbol { + anchors.verticalCenter: parent.verticalCenter + visible: root.agentSwitchable + text: "expand_more" + iconSize: Math.round(16 * IrisStyle.density) + color: IrisStyle.muted + rotation: agentMenu.open ? 180 : 0 + Behavior on rotation { + NumberAnimation { + duration: IrisStyle.duration(140) + easing.type: Easing.BezierSpline + } + } + } + } + + MouseArea { + anchors.fill: parent + enabled: root.agentSwitchable + hoverEnabled: true + cursorShape: root.agentSwitchable ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: agentMenu.open ? agentMenu.open = false : root.openAgentMenu() + } + } + + IrisText { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Eyebrow + color: IrisStyle.muted + text: "AI USAGE" + } + } + + Row { + id: primaryRow + width: parent.width + spacing: root.gap + visible: usageModel.has + readonly property bool spendHere: !root.planAgent && root.hasSpend + readonly property int count: (root.hasPlan ? 1 : 0) + 1 + (spendHere ? 1 : 0) + readonly property real half: Math.round((width - spacing) / 2) + readonly property real rowHeight: Math.max( + root.hasPlan ? limitsCard.implicitHeight : 0, + nowCard.implicitHeight, + primaryRow.spendHere ? spendCard.implicitHeight : 0) + + AiUsageLimitsCard { + page: root + usage: usageModel + id: limitsCard + visible: root.hasPlan + width: primaryRow.count > 1 ? primaryRow.half : primaryRow.width + height: primaryRow.rowHeight + } + AiUsageNowCard { + page: root + usage: usageModel + id: nowCard + width: primaryRow.count > 1 ? primaryRow.half : primaryRow.width + height: primaryRow.rowHeight + } + AiUsageSpendCard { + page: root + usage: usageModel + id: spendCard + visible: primaryRow.spendHere + width: primaryRow.count > 1 ? primaryRow.half : primaryRow.width + height: primaryRow.rowHeight + } + } + + AiUsageTrendCard { + page: root + usage: usageModel + width: parent.width + visible: usageModel.has && root.trendBuckets.length > 0 + onPeriodMenuRequested: trigger => root.openPeriodMenu(trigger) + } + + AiUsageModelsCard { + page: root + usage: usageModel + width: parent.width + visible: usageModel.has && root.periodModels.length > 0 + } + + AiUsageActivityCard { + page: root + usage: usageModel + width: parent.width + visible: usageModel.has && usageModel.days.length > 0 + } + + AiUsageEmptyCard { + page: root + usage: usageModel + width: parent.width + height: implicitHeight + // Not while an agent switch is still loading: the old card holds the + // frame, and a no-cache agent shows this only once the load failed. + visible: !usageModel.has && !usageModel.switching + } + } + + // One dropdown implementation serves both pickers. + MouseArea { + anchors.fill: parent + visible: agentMenu.open || periodMenu.open + z: 88 + onClicked: { + agentMenu.open = false + periodMenu.open = false + } + } + AiUsageMenu { + id: agentMenu + z: 92 + options: root.agentOptions + currentValue: root.tool + onPicked: value => CustomWidgets.setConfigValue("ai-usage", "tool", value) + } + AiUsageMenu { + id: periodMenu + z: 91 + menuWidth: 158 + rowHeight: Math.round(28 * IrisStyle.density) + boldCurrent: false + options: root.periodOptions + currentValue: root.periodKey + onPicked: value => CustomWidgets.setConfigValue("ai-usage", "period", value) + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml new file mode 100644 index 000000000..74a337184 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml @@ -0,0 +1,416 @@ +pragma ComponentBehavior: Bound + +// Reads one tool's usage cache the way the shared collectors leave it: +// ~/.cache/-usage.json, written by ryoku-ai-usage.service. Mirrors the +// legacy parser of the shell's AiUsage service, kept local so a module failure +// stays local (the shell service is not imported across namespaces). +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + + property string tool: "opencode" + property bool refreshing: false + // True while a tool switch is waiting for the new cache. The old card stays + // on screen instead of flashing the empty state and its refresh button. + property bool switching: false + + // ── parsed state ── + property bool has: false + property bool fresh: false + property int pct5h: 0 + property int percent7d: 0 + property int reset5hTs: 0 + property int reset7dTs: 0 + // Relative times recompute when this advances (see relative()). + property int tick: 0 + property string plan: "" + property string windowTokens: "" // "_tokens_used / _window_limit" + property string weekTokens: "" // "_7d_tokens / _7d_limit" + property int today: 0 // tokens today + property var recent: [] // [{date, tokens}] fallback when the cache has no _days + property var days: [] // [{date, tokens}] full history from the collector + property var hours: [] // [{start, tokens}] the last 48 clock hours + property var sessions: [] // [{id, title, directory, started, updated, tokens, cost, running}] + property var periods: ({}) // "7"/"30"/"all" → {tokens, cacheRate, cost, sessions, models} + property var windows: [] // [{label, pct, caption, resetTs}] server-reported quota windows + property var spending: ({}) + readonly property var runningSessions: root.sessions.filter(s => s && s.running) + // A provider plan is what makes a 5h/7d window meaningful. The local + // OpenCode collector says "local messages" and invents its budgets, so + // those percentages would be fiction and the card stays away. + readonly property bool hasPlan: { + if (!root.has) return false + const name = String(root.plan || "").trim().toLowerCase() + if (name.length === 0) + return root.windows.length > 0 || root.reset5hTs > 0 || root.reset7dTs > 0 + return name !== "local messages" && name !== "local" && name !== "unknown" + } + function period(key: string): var { + return root.periods?.[key] ?? null + } + + function normalizeWindows(data: var): var { + const rows = [] + if (data._windows instanceof Array) { + for (const window of data._windows) { + const minutes = Number(window?.minutes) || 0 + if (minutes <= 0) continue + const label = minutes % 1440 === 0 ? (minutes / 1440) + "d" + : minutes % 60 === 0 ? (minutes / 60) + "h" : minutes + "m" + rows.push({ + label: label, + caption: minutes === 300 ? root.windowTokens + : minutes === 10080 ? root.weekTokens : "", + pct: root.pct(String(Number(window?.utilization) || 0)), + resetTs: parseInt(window?.reset) || 0 + }) + } + } else { + // Older caches have only named windows. An explicit empty array + // means the account reports no windows; never invent quotas then. + if (String(data["5h-utilization"] ?? "").length > 0) + rows.push({ label: "5h", caption: root.windowTokens, + pct: root.pct5h, resetTs: root.reset5hTs }) + if (String(data["7d-utilization"] ?? "").length > 0) + rows.push({ label: "7d", caption: root.weekTokens, + pct: root.percent7d, resetTs: root.reset7dTs }) + } + return rows + } + + // Period rows come from the collector with a raw model name; give them the + // same short label and numeric fields the rest of the card expects. + function shortName(name: string): string { + return String(name || "").replace( + /\s*\((?:opencode|openrouter|openai|claude|codex|deepseek)\)$/i, "") + } + function normalizePeriods(source: var): var { + const out = ({}) + for (const key in source) { + const period = source[key] ?? ({}) + const rows = [] + for (const model of (period.models ?? [])) { + rows.push(Object.assign({}, model, { + name: String(model.name || ""), + nameShort: root.shortName(model.name), + totalLabel: String(model.totalLabel || root.kmg(Number(model.total) || 0)), + cacheRate: Number(model.cacheRate) || 0, + pct: Math.max(0, Math.min(100, Number(model.pct) || 0)) + })) + } + out[key] = Object.assign({}, period, { + tokens: Number(period.tokens) || 0, + cacheRate: Number(period.cacheRate) || 0, + cost: Number(period.cost) || 0, + sessions: Number(period.sessions) || 0, + models: rows + }) + } + return out + } + // Newest write any session made, in ms: what "Now" counts from. + readonly property double lastActivityMs: { + let latest = 0 + for (const session of root.sessions) + latest = Math.max(latest, Number(session?.updated ?? 0)) + return latest + } + // "18 min. ago", and nothing at all before the first message. + function relative(ms: double): string { + root.tick + if (!(ms > 0)) return "" + const mins = Math.max(0, Math.round((Date.now() - ms) / 60000)) + if (mins < 2) return "just now" + if (mins < 60) return mins + " min. ago" + const h = Math.floor(mins / 60) + if (h < 24) return h + (h === 1 ? " hr. ago" : " hrs. ago") + const d = Math.floor(h / 24) + return d + (d === 1 ? " day ago" : " days ago") + } + + // When a rate-limit window resets, short enough for a card line. + function resetIn(ts: double): string { + root.tick + if (!(ts > 0)) return "" + const seconds = ts - Math.floor(Date.now() / 1000) + if (seconds <= 0) return "reset due" + const minutes = Math.ceil(seconds / 60) + if (minutes < 60) return "resets in " + minutes + " min" + const hours = Math.ceil(minutes / 60) + if (hours < 24) return "resets in " + hours + " hr" + return "resets in " + Math.ceil(hours / 24) + " days" + } + + Component.onCompleted: cacheFile.reload() + onToolChanged: { + // Keep the current numbers visible while the other cache loads. A + // switch whose cache is missing or unreadable ends in empty(), either + // from onLoadFailed or from switchTimer, so nothing stays stale. + root.switching = true + switchTimer.restart() + cacheFile.reload() + } + + function empty() { + root.has = false + root.fresh = false + root.pct5h = 0 + root.percent7d = 0 + root.reset5hTs = 0 + root.reset7dTs = 0 + root.plan = "" + root.windowTokens = "" + root.weekTokens = "" + root.today = 0 + root.recent = [] + root.days = [] + root.hours = [] + root.sessions = [] + root.periods = ({}) + root.windows = [] + root.spending = ({}) + } + + function pct(value: string): int { + return Math.max(0, Math.min(100, Math.round((parseFloat(value) || 0) * 100))) + } + + function kmg(value: double): string { + if (value >= 1000000) { + const m = value / 1000000 + return m.toFixed(2).replace(/\.?0+$/, "") + "M" + } + if (value >= 1000) { + const k = value / 1000 + return k >= 100 ? Math.round(k) + "K" : k.toFixed(1).replace(/\.0$/, "") + "K" + } + return String(value) + } + + // The OpenCode collector ships as /usr/bin/opencode-usage (ryoku-rashin). + // Refresh runs it directly, with the packaged service as a fallback for + // every tool. A dev box can point the env override at a checkout copy. + readonly property string collectorPath: { + const custom = String(Quickshell.env("RYOKU_AI_USAGE_COLLECTOR") || "") + return custom.length > 0 ? custom : "opencode-usage" + } + readonly property var collectorCommand: root.tool === "opencode" + ? [root.collectorPath] + : ["ryoku-host", "svc", "--user", "start", "ryoku-ai-usage.service"] + + function refresh(): void { + if (root.refreshing) return + root.refreshing = true + staleTimer.restart() + collector.running = false + collector.running = true + } + + function parse(text: string, modifiedAt: int): void { + root.empty() + try { + const data = JSON.parse(String(text || "").trim()) + if (Object.keys(data).length === 0) return + root.has = true + root.fresh = modifiedAt > 0 + && (Date.now() / 1000 - modifiedAt) < 900 + && data._source !== "stale" + root.pct5h = root.pct(data["5h-utilization"]) + root.percent7d = root.pct(data["7d-utilization"]) + root.reset5hTs = parseInt(data["5h-reset"]) || 0 + root.reset7dTs = parseInt(data["7d-reset"]) || 0 + root.plan = String(data._plan || "") + const used = Number(data._tokens_used) || 0 + const limit = Number(data._window_limit) || 0 + root.windowTokens = used > 0 + ? root.kmg(used) + " / " + root.kmg(limit) + : "" + const weekUsed = Number(data._7d_tokens) || 0 + const weekLimit = Number(data._7d_limit) || 0 + root.weekTokens = weekLimit > 0 + ? root.kmg(weekUsed) + " / " + root.kmg(weekLimit) + : "" + root.windows = root.normalizeWindows(data) + root.today = Number(data._today_tokens) || 0 + root.spending = data._spending && typeof data._spending === "object" + ? data._spending : ({}) + if (data._recent_days instanceof Array) { + const days = data._recent_days + root.recent = days.slice(Math.max(0, days.length - 7)) + } + if (data._hours instanceof Array && data._hours.length > 0) { + const hours = [] + for (const hour of data._hours) { + // Older Codex caches stamped hours in seconds; the widget + // builds slots in milliseconds, so normalise at the door. + const start = Number(hour?.start) || 0 + hours.push({ + start: start > 0 && start < 100000000000 ? start * 1000 : start, + tokens: Number(hour?.tokens) || 0, + cacheRead: Number(hour?.cacheRead) || 0 + }) + } + root.hours = hours + } + if (data._days instanceof Array && data._days.length > 0) { + const history = [] + for (const day of data._days) { + history.push({ + date: String(day?.date ?? ""), + tokens: Number(day?.tokens) || 0, + cacheRead: Number(day?.cacheRead) || 0, + cost: Number(day?.cost) || 0 + }) + } + root.days = history + } else { + // Older collectors carry only the seven-day window. + root.days = root.recent.map(day => ({ date: String(day?.date ?? ""), tokens: Number(day?.tokens) || 0, cacheRead: 0, cost: 0 })) + } + const sessionRows = [] + for (const s of (data._sessions instanceof Array ? data._sessions : [])) { + sessionRows.push({ + id: String(s?.id ?? ""), + title: String(s?.title ?? ""), + directory: String(s?.directory ?? ""), + started: Number(s?.started) || 0, + updated: Number(s?.updated) || 0, + tokens: Number(s?.tokens) || 0, + cost: Number(s?.cost) || 0, + running: s?.running === true + }) + } + root.sessions = sessionRows + root.periods = (data._periods && typeof data._periods === "object") + ? root.normalizePeriods(data._periods) : ({}) + } catch (e) { + console.warn("[ai-usage] parse failed:", e) + } finally { + // The load has landed, as data or as a genuine empty cache. + switchTimer.stop() + root.switching = false + } + } + + // The collectors write under ~/.cache whatever XDG_CACHE_HOME says, so the + // reader follows them rather than the XDG variable (same as the shell). + readonly property string cacheDir: Quickshell.env("HOME") + "/.cache" + readonly property string cachePath: root.cacheDir + "/" + root.tool + "-usage.json" + + property string _pendingText: "" + property bool _missing: true + + FileView { + id: cacheFile + path: root.cachePath + blockLoading: false + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + root._missing = false + root._pendingText = cacheFile.text() + stat.running = false + stat.running = true + } + onLoadFailed: { + root._missing = true + root._pendingText = "" + switchTimer.stop() + root.switching = false + root.empty() + } + } + + Process { + id: stat + command: ["stat", "-c", "%Y", root.cachePath] + stdout: StdioCollector { id: statOutput } + onExited: { + if (!root._missing) + root.parse(root._pendingText, parseInt(statOutput.text.trim()) || 0) + } + } + // A cache that appeared after first scan is a plain reload away. + Timer { + interval: 6000 + repeat: true + running: root._missing + onTriggered: cacheFile.reload() + } + + Process { + id: collector + command: root.collectorCommand + onExited: (exitCode, exitStatus) => { + if (root.tool === "opencode" && exitCode !== 0) { + serviceFallback.running = true + return + } + _pendingReloadTimer.restart() + } + } + Process { + id: serviceFallback + command: ["ryoku-host", "svc", "--user", "start", "ryoku-ai-usage.service"] + onExited: (exitCode, exitStatus) => { + _pendingReloadTimer.restart() + } + } + Timer { + id: _pendingReloadTimer + interval: 600 + onTriggered: { + // The collector has exited by now: release the refresh button even + // when the run wrote nothing new, instead of waiting the full + // stale timeout. + root.refreshing = false + cacheFile.reload() + } + } + Timer { + id: staleTimer + interval: 10000 + onTriggered: root.refreshing = false + } + // Last resort for a switch that never lands (an unreadable cache that is + // neither loaded nor failed): drop the old numbers after a beat instead of + // leaving them on the card for good. + Timer { + id: switchTimer + interval: 1200 + onTriggered: { + if (!root.switching) + return + root.switching = false + root.empty() + } + } + // Relative times ("18 min. ago", a session's elapsed) have to move while + // the card sits on the page; one ticker keeps them honest. + Timer { + interval: 30000 + repeat: true + triggeredOnStart: true + running: root.has + onTriggered: root.tick++ + } + // The page has no reload button: the collector runs again every minute + // while the module is on screen, so the numbers are never older than that. + Timer { + interval: 60000 + repeat: true + triggeredOnStart: true + running: root.has + onTriggered: root.refresh() + } + Connections { + target: cacheFile + function onFileChanged() { + root.refreshing = false + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AgentAvailability.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AgentAvailability.qml new file mode 100644 index 000000000..a28481a14 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AgentAvailability.qml @@ -0,0 +1,111 @@ +pragma ComponentBehavior: Bound + +// Which agents this machine has actually used. Every collector leaves +// ~/.cache/-usage.json behind only after it has run, so the cache is the +// whole answer: a file means the agent is worth offering in the menu, no file +// means it never ran here. +// +// A FileView on a missing path cannot watch for that path appearing later, so +// the timer reloads all three. `ready` stays false until each FileView has +// reported at least once, so callers never read "no caches yet" as "no agents". +import QtQuick +import Quickshell +import Quickshell.Io + +Item { + id: root + + readonly property string cacheDir: Quickshell.env("HOME") + "/.cache" + + property bool _opencode: false + property bool _claude: false + property bool _codex: false + property bool _opencodeSeen: false + property bool _claudeSeen: false + property bool _codexSeen: false + + readonly property bool opencode: root._opencode + readonly property bool claude: root._claude + readonly property bool codex: root._codex + readonly property bool ready: root._opencodeSeen && root._claudeSeen && root._codexSeen + + Component.onCompleted: root.reloadAll() + + function reloadAll(): void { + opencodeFile.reload() + claudeFile.reload() + codexFile.reload() + } + + FileView { + id: opencodeFile + path: root.cacheDir + "/opencode-usage.json" + blockLoading: false + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + root._opencode = true + root._opencodeSeen = true + } + onLoadFailed: { + root._opencode = false + root._opencodeSeen = true + } + } + + FileView { + id: claudeFile + path: root.cacheDir + "/claude-usage.json" + blockLoading: false + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + root._claude = true + root._claudeSeen = true + } + onLoadFailed: { + root._claude = false + root._claudeSeen = true + } + } + + FileView { + id: codexFile + path: root.cacheDir + "/codex-usage.json" + blockLoading: false + watchChanges: true + printErrors: false + onFileChanged: reload() + onLoaded: { + root._codex = true + root._codexSeen = true + } + onLoadFailed: { + root._codex = false + root._codexSeen = true + } + } + + // A FileView normally reports a missing file, but do not let one silent + // probe keep the menu disabled forever: after a moment, accept what has + // been seen and let the periodic reload fill in the rest. + Timer { + interval: 1200 + repeat: false + running: true + onTriggered: { + root._opencodeSeen = true + root._claudeSeen = true + root._codexSeen = true + } + } + Timer { + interval: 30000 + repeat: true + triggeredOnStart: true + running: true + onTriggered: root.reloadAll() + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageActivityCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageActivityCard.qml new file mode 100644 index 000000000..f4363d3bf --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageActivityCard.qml @@ -0,0 +1,309 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +AiUsageGlassCard { + required property var usage + id: card + property bool expanded: false + radius: page.radius + implicitHeight: body.implicitHeight + 2 * page.pad + + readonly property string todayKey: Qt.formatDate(new Date(), "yyyy-MM-dd") + readonly property int cellSize: Math.max(7, Math.round(11 * IrisStyle.density)) + readonly property int cellGap: Math.max(2, Math.round(3 * IrisStyle.density)) + readonly property int cellRadius: Math.max(2, Math.round(3 * IrisStyle.density)) + + // Everything a day spent, cache reads included. + function dayWeight(day) { + return Number(day?.tokens ?? 0) + Number(day?.cacheRead ?? 0) + } + + // Sixty-three calendar days ending today in nine full seven-day columns. + // No partial columns; missing days remain dim squares. + readonly property var map: { + const days = usage.days + if (!days || days.length === 0) + return ({ weeks: [], total: 0, active: 0, busiest: null, span: 0, thresholds: [] }) + const byDate = ({}) + for (const day of days) + byDate[String(day?.date ?? "")] = card.dayWeight(day) + const today = new Date() + today.setHours(12, 0, 0, 0) + const start = new Date(today.getTime()) + start.setDate(start.getDate() - 62) + const cursor = new Date(start.getTime()) + const weekCount = 9 + let active = 0 + let busiest = null + let span = 0 + const values = [] + const weeks = [] + for (let w = 0; w < weekCount; w++) { + const column = [] + for (let r = 0; r < 7; r++) { + const key = Qt.formatDate(cursor, "yyyy-MM-dd") + if (cursor.getTime() < start.getTime() || cursor.getTime() > today.getTime()) { + // Dates outside the 63-day window are padding, not zero. + column.push(null) + } else { + const recorded = byDate[key] !== undefined + const value = Number(byDate[key] ?? 0) || 0 + column.push({ date: key, value: value }) + if (value > 0) { + active += 1 + values.push(value) + } + if (!busiest || value > busiest.value) + busiest = { date: key, value: value } + if (recorded && span === 0) + span = Math.round((today.getTime() - cursor.getTime()) / 86400000) + 1 + } + cursor.setDate(cursor.getDate() + 1) + } + weeks.push(column) + } + values.sort((a, b) => a - b) + const thresholds = values.length > 0 + ? [0.25, 0.5, 0.75].map(q => values[Math.min(values.length - 1, Math.floor(values.length * q))]) + : [] + return ({ weeks: weeks, active: active, busiest: busiest, + span: span, thresholds: thresholds }) + } + + // Consecutive active days ending today; a day still in progress counts + // back from yesterday rather than breaking the run. + readonly property int streak: { + const days = usage.days + if (!days || days.length === 0) return 0 + const byDate = ({}) + for (const day of days) + byDate[String(day?.date ?? "")] = card.dayWeight(day) + const cursor = new Date() + cursor.setHours(12, 0, 0, 0) + if (!(Number(byDate[Qt.formatDate(cursor, "yyyy-MM-dd")] ?? 0) > 0)) + cursor.setDate(cursor.getDate() - 1) + let count = 0 + while (Number(byDate[Qt.formatDate(cursor, "yyyy-MM-dd")] ?? 0) > 0) { + count += 1 + cursor.setDate(cursor.getDate() - 1) + } + return count + } + + function spanLabel(days: int): string { + if (days <= 0) return "" + if (days >= 7 && days % 7 === 0) + return (days / 7) + (days === 7 ? " week" : " weeks") + return days + (days === 1 ? " day" : " days") + } + + // The card's headline is the whole history, not just the mapped weeks. + readonly property double allTimeTotal: { + let total = 0 + for (const day of usage.days) total += card.dayWeight(day) + return total + } + + function cellColor(cell) { + if (!cell) return "transparent" + const value = Number(cell.value ?? 0) + if (value <= 0) return ColorUtils.applyAlpha(IrisStyle.text, 0.07) + const level = card.map.thresholds.filter(limit => value > limit).length + return ColorUtils.applyAlpha(IrisStyle.accent, [0.28, 0.45, 0.65, 0.9][Math.min(3, level)]) + } + + Column { + id: body + x: page.pad + y: page.pad + width: card.width - 2 * page.pad + spacing: page.step * 2 + + AiUsageFoldHeader { + title: "Activity" + expanded: card.expanded + onToggled: card.expanded = !card.expanded + // The run of active days, with its own explanation on hover. + Item { + id: streakBadge + visible: card.streak > 1 + width: streakIcon.width + Math.round(2 * IrisStyle.density) + streakText.width + height: Math.max(streakIcon.height, streakText.height) + MaterialSymbol { + id: streakIcon + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "local_fire_department" + iconSize: Math.round(15 * IrisStyle.density) + color: IrisStyle.warning + } + IrisText { + id: streakText + anchors.left: streakIcon.right + anchors.leftMargin: Math.round(2 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + text: String(card.streak) + color: IrisStyle.warning + font.pixelSize: Math.round(10 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + MouseArea { + id: badgeArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: card.expanded = !card.expanded + } + IrisSurface { + id: streakTip + visible: badgeArea.containsMouse + z: 10 + raised: true + outlined: true + radius: IrisStyle.radiusSmall + anchors.horizontalCenter: parent.horizontalCenter + anchors.bottom: parent.top + anchors.bottomMargin: Math.round(4 * IrisStyle.density) + width: streakTipText.implicitWidth + 2 * Math.round(6 * IrisStyle.density) + height: streakTipText.implicitHeight + 2 * Math.round(3 * IrisStyle.density) + IrisText { + id: streakTipText + anchors.centerIn: parent + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: card.streak + "-day streak" + } + } + } + } + + Item { + id: content + width: parent.width + height: Math.max(heat.implicitHeight, Math.round(80 * IrisStyle.density)) + visible: card.expanded + + Row { + id: heat + anchors.left: parent.left + anchors.top: parent.top + spacing: card.cellGap + Repeater { + model: card.map.weeks + Column { + required property var modelData + spacing: card.cellGap + Repeater { + model: modelData + Rectangle { + id: heatCell + required property var modelData + width: card.cellSize + height: width + radius: card.cellRadius + color: card.cellColor(heatCell.modelData) + border.width: heatCell.modelData !== null + && String(heatCell.modelData.date) === card.todayKey ? 1 : 0 + border.color: IrisStyle.accent + } + } + } + } + } + + // The figures take the rest of the row and sit on the card's + // inner right edge, as the reference does. + Item { + id: figures + anchors.left: heat.right + anchors.leftMargin: Math.round(16 * IrisStyle.density) + anchors.right: parent.right + anchors.top: parent.top + anchors.bottom: parent.bottom + + IrisText { + id: totalValue + anchors.top: parent.top + anchors.left: parent.left + role: IrisText.Metric + text: usage.kmg(card.allTimeTotal) + font.pixelSize: Math.round(21 * IrisStyle.density) + } + IrisText { + id: spanValue + anchors.top: totalValue.bottom + anchors.topMargin: Math.round(1 * IrisStyle.density) + anchors.left: parent.left + role: IrisText.Eyebrow + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "all time" + } + Item { + id: activeRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: busiestRow.top + anchors.bottomMargin: Math.round(3 * IrisStyle.density) + height: Math.max(activeLabel.implicitHeight, activeValue.implicitHeight) + IrisText { + id: activeLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Eyebrow + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "Active days" + } + IrisText { + id: activeValue + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Meta + font.pixelSize: Math.round(10 * IrisStyle.density) + text: String(card.map.active) + } + } + Item { + id: busiestRow + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: Math.max(busiestLabel.implicitHeight, busiestValue.implicitHeight) + IrisText { + id: busiestLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Eyebrow + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "Busiest day" + } + IrisText { + id: busiestValue + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Meta + font.pixelSize: Math.round(10 * IrisStyle.density) + text: card.map.busiest && card.map.busiest.value > 0 + ? page.dayLabel(card.map.busiest.date) + " · " + + usage.kmg(card.map.busiest.value) + : "—" + } + } + } + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageEmptyCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageEmptyCard.qml new file mode 100644 index 000000000..66c1d3a81 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageEmptyCard.qml @@ -0,0 +1,60 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +AiUsageGlassCard { + required property var usage + id: card + radius: page.radius + implicitHeight: body.implicitHeight + 2 * page.pad + + Column { + id: body + x: page.pad + y: page.pad + width: card.width - 2 * page.pad + spacing: page.step * 3 + + Item { + width: parent.width + height: Math.max(emptyTitle.implicitHeight, refreshBtn.implicitHeight) + IrisText { + id: emptyTitle + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "AI usage · " + page.toolLabel + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + IrisIconButton { + id: refreshBtn + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + materialIcon: "refresh" + iconSize: Math.round(15 * IrisStyle.density) + onClicked: usage.refresh() + } + } + IrisText { + width: parent.width + role: IrisText.Meta + color: IrisStyle.muted + text: "No " + page.tool + " usage yet. Run " + page.tool + + ", then tap ↻ — the collector writes ~/.cache/" + page.tool + "-usage.json." + wrapMode: Text.WordWrap + } + } +} + +// ─── pieces ────────────────────────────────────────────────────────── + diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageFoldHeader.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageFoldHeader.qml new file mode 100644 index 000000000..c7951e35c --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageFoldHeader.qml @@ -0,0 +1,60 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +Item { + id: header + property string title: "" + property bool expanded: false + signal toggled() + default property alias accessories: accessoryRow.data + width: parent ? parent.width : 0 + height: Math.max(Math.round(24 * IrisStyle.density), titleText.implicitHeight, + accessoryRow.implicitHeight, chevronIcon.implicitHeight) + + MouseArea { + anchors.fill: parent + cursorShape: Qt.PointingHandCursor + onClicked: header.toggled() + } + IrisText { + id: titleText + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: header.title + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + Row { + id: accessoryRow + anchors.right: chevronIcon.left + anchors.rightMargin: Math.round(8 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + spacing: Math.round(6 * IrisStyle.density) + } + MaterialSymbol { + id: chevronIcon + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "expand_more" + iconSize: Math.round(16 * IrisStyle.density) + color: IrisStyle.subtext + rotation: header.expanded ? 180 : 0 + Behavior on rotation { + NumberAnimation { + duration: IrisStyle.duration(160) + easing.type: Easing.BezierSpline + } + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassCard.qml new file mode 100644 index 000000000..177adfe9a --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassCard.qml @@ -0,0 +1,35 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +IrisSurface { + required property var page + id: card + quiet: true + radius: page.radius + + AiUsageGlassPane { + anchors.fill: parent + page: card.page + z: -1 + } + + Rectangle { + anchors.fill: parent + radius: card.radius + z: -1 + color: "transparent" + border.width: 1 + border.color: ColorUtils.applyAlpha(IrisStyle.text, 0.10) + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassPane.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassPane.qml new file mode 100644 index 000000000..b28bbe0e5 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassPane.qml @@ -0,0 +1,67 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +ClippingRectangle { + required property var page + id: pane + radius: page.radius + // A transparent ClippingRectangle stops painting its children (see + // IrisIsland's chassis); this is the same near-transparent body clip. + color: IrisStyle.bodyClip + readonly property var screen: pane.QsWindow.window?.screen ?? page.targetScreen + readonly property point at: { + // mapToItem does not track its ancestors as binding dependencies, so + // a layout that moves the page (opening, a card growing, a new + // workspace) left this point at the one it had before the move: the + // glass sampled the wallpaper near the screen's top edge and read as + // a dark card until some unrelated resize re-evaluated it. Read every + // ancestor's position to keep the binding honest. + let walk = pane + while (walk) { + void (walk.x + walk.y) + walk = walk.parent + } + void (pane.width + pane.height) + return pane.mapToItem(null, 0, 0) + } + + Image { + id: wallpaper + x: -pane.at.x + y: -pane.at.y + width: pane.screen?.width ?? 1920 + height: pane.screen?.height ?? 1080 + source: WallpaperListener.wallpaperUrlForScreen(pane.screen) + fillMode: Image.PreserveAspectCrop + cache: true + asynchronous: true + sourceSize.width: width + sourceSize.height: height + visible: status === Image.Ready + layer.enabled: visible + layer.effect: MultiEffect { + source: wallpaper + anchors.fill: source + blurEnabled: true + blurMax: 64 + blur: 1 + saturation: 0.08 + } + } + + Rectangle { + anchors.fill: parent + color: ColorUtils.applyAlpha(IrisStyle.surfaceOpaque, 0.38) + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageLimitsCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageLimitsCard.qml new file mode 100644 index 000000000..81748d520 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageLimitsCard.qml @@ -0,0 +1,110 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +AiUsageGlassCard { + required property var usage + id: card + radius: page.radius + implicitHeight: body.implicitHeight + 2 * page.pad + // The primary row stretches every card to the tallest one, so this card can + // be taller than its content. Centre the content in that spare height so + // the blank space splits above and below instead of collecting under the + // text. At the content-sized height the offset is exactly page.pad, so the + // natural card does not move. + readonly property real spareHeight: card.height - card.implicitHeight + + Column { + id: body + x: page.pad + y: page.pad + Math.round(Math.max(0, card.spareHeight) / 2) + width: card.width - 2 * page.pad + spacing: page.step * 3 + + Item { + width: parent.width + height: Math.max(agentName.implicitHeight, planText.implicitHeight) + IrisText { + id: agentName + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: page.toolLabel + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + IrisText { + id: planText + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: usage.plan.length > 0 ? usage.plan : "" + } + } + + Item { + width: parent.width + height: todayRow.implicitHeight + Row { + id: todayRow + width: parent.width + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Math.round(7 * IrisStyle.density) + IrisText { + id: todayMetric + role: IrisText.Metric + text: usage.kmg(usage.today) + font.pixelSize: Math.round(26 * IrisStyle.density) + } + Column { + width: Math.max(0, todayRow.width - todayMetric.width - todayRow.spacing) + anchors.verticalCenter: todayMetric.verticalCenter + spacing: 0 + IrisText { + width: parent.width + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "tokens today" + elide: Text.ElideRight + maximumLineCount: 1 + } + IrisText { + width: parent.width + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "(incl. cached)" + elide: Text.ElideRight + maximumLineCount: 1 + } + } + } + } + + Repeater { + model: card.usage.windows + AiUsageWindowRow { + required property var modelData + width: parent.width + label: modelData.label + caption: modelData.caption + share: modelData.pct + "% used" + proportion: modelData.pct / 100 + resetLabel: card.usage.resetIn(modelData.resetTs) + } + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml new file mode 100644 index 000000000..f4a05a3be --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml @@ -0,0 +1,93 @@ +pragma ComponentBehavior: Bound + +// One dropdown shared by the agent and period pickers: options, a current +// marker, hover and a picked signal. The page owns the veil and the position, +// so the menu only draws itself. +import QtQuick +import Quickshell +import Quickshell.Widgets +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.iris.style +import inir.modules.iris.components + +IrisSurface { + id: root + + property bool open: false + property var options: [] + property string currentValue: "" + property real menuWidth: 172 + property int rowHeight: Math.round(30 * IrisStyle.density) + property bool boldCurrent: true + signal picked(string value) + + visible: root.open + raised: true + outlined: true + width: Math.round(root.menuWidth * IrisStyle.density) + height: column.implicitHeight + 2 * Math.round(6 * IrisStyle.density) + radius: IrisStyle.radiusTile + + Rectangle { + anchors.fill: parent + radius: root.radius + color: "transparent" + border.width: 1 + border.color: IrisStyle.borderStrong + } + + Column { + id: column + x: Math.round(6 * IrisStyle.density) + y: Math.round(6 * IrisStyle.density) + width: root.width - 2 * Math.round(6 * IrisStyle.density) + + Repeater { + model: root.options + Item { + id: row + required property var modelData + width: column.width + height: root.rowHeight + readonly property bool current: String(modelData?.value ?? "") === root.currentValue + + Rectangle { + anchors.fill: parent + radius: IrisStyle.radiusSmall + color: row.current || rowArea.containsMouse + ? IrisStyle.fillHover : "transparent" + } + IrisText { + anchors.left: parent.left + anchors.leftMargin: Math.round(9 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + text: String(row.modelData?.label ?? "") + font.pixelSize: IrisStyle.typeLabel + font.weight: IrisStyle.weight( + row.current && root.boldCurrent ? Font.DemiBold : Font.Medium) + color: row.current ? IrisStyle.accent : IrisStyle.text + } + MaterialSymbol { + anchors.right: parent.right + anchors.rightMargin: Math.round(9 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + visible: row.current + text: "check" + iconSize: Math.round(14 * IrisStyle.density) + color: IrisStyle.accent + } + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + root.open = false + root.picked(String(row.modelData?.value ?? "")) + } + } + } + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelRow.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelRow.qml new file mode 100644 index 000000000..2177619ea --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelRow.qml @@ -0,0 +1,77 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +Item { + required property var page + id: modelRow + required property var modelData + readonly property int rowGap: Math.round(4 * IrisStyle.density) + readonly property int meterHeight: Math.round(5 * IrisStyle.density) + readonly property int nameHeight: Math.max(nameText.implicitHeight, + Math.round(14 * IrisStyle.density)) + height: nameHeight + rowGap + meterHeight + rowGap + detailText.implicitHeight + implicitHeight: height + + IrisText { + id: nameText + anchors.left: parent.left + anchors.top: parent.top + width: parent.width * 0.72 + height: modelRow.nameHeight + text: modelRow.modelData?.nameShort ?? "" + elide: Text.ElideRight + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.Medium) + } + IrisText { + anchors.right: parent.right + anchors.top: parent.top + text: modelRow.modelData?.totalWithCacheReadLabel + ?? modelRow.modelData?.totalLabel ?? "" + role: IrisText.Metric + color: IrisStyle.accent + font.family: IrisStyle.fontNumbers + font.pixelSize: Math.round(13 * IrisStyle.density) + } + Rectangle { + id: modelMeter + anchors.left: parent.left + anchors.right: parent.right + anchors.top: nameText.bottom + anchors.topMargin: modelRow.rowGap + height: modelRow.meterHeight + radius: height / 2 + color: ColorUtils.applyAlpha(IrisStyle.muted, 0.18) + Rectangle { + width: parent.width * Math.max(0, Math.min(100, + Number(modelRow.modelData?.pct ?? 0))) / 100 + height: parent.height + radius: parent.radius + color: IrisStyle.accent + opacity: 0.85 + } + } + IrisText { + id: detailText + anchors.left: parent.left + anchors.right: parent.right + anchors.top: modelMeter.bottom + anchors.topMargin: modelRow.rowGap + text: page.modelDetail(modelRow.modelData) + elide: Text.ElideRight + role: IrisText.Eyebrow + font.pixelSize: Math.round(11 * IrisStyle.density) + color: IrisStyle.subtext + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelsCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelsCard.qml new file mode 100644 index 000000000..7ef335ff7 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelsCard.qml @@ -0,0 +1,70 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +AiUsageGlassCard { + required property var usage + id: card + property bool expanded: false + radius: page.radius + implicitHeight: body.implicitHeight + 2 * page.pad + + Column { + id: body + x: page.pad + y: page.pad + width: card.width - 2 * page.pad + spacing: page.step * 3 + + AiUsageFoldHeader { + title: "Models" + expanded: card.expanded + onToggled: card.expanded = !card.expanded + IrisText { + id: modelsCount + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: page.periodModels.length > page.topModels + ? "top " + page.topModels + " of " + page.periodModels.length + : page.periodModels.length + " model" + + (page.periodModels.length === 1 ? "" : "s") + } + } + + Column { + id: content + width: parent.width + visible: card.expanded + spacing: page.step * 3 + + Repeater { + model: page.periodModels.slice(0, page.topModels) + AiUsageModelRow { + page: card.page + width: content.width + } + } + + IrisText { + width: parent.width + visible: page.periodModels.length > page.topModels + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(9 * IrisStyle.density) + text: "+" + (page.periodModels.length - page.topModels) + " more model" + + (page.periodModels.length - page.topModels === 1 ? "" : "s") + } + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageNowCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageNowCard.qml new file mode 100644 index 000000000..4b42bd0ad --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageNowCard.qml @@ -0,0 +1,189 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +AiUsageGlassCard { + required property var usage + id: card + radius: page.radius + // One gap for the block: between the hairline and the Spending title, and + // the least air a session keeps above the hairline. + readonly property int spendGap: Math.round(10 * IrisStyle.density) + // What the Spending block owns at the bottom of the card: the hairline, a + // clear gap under it, the figures, and padY of card below the amount. + readonly property int spendBlockHeight: page.integratedSpend + ? 1 + card.spendGap + spendBody.implicitHeight + spendBody.padY : 0 + // Content-sized, two rules decide the height. The sessions keep at least + // spendGap above and below, and the block takes the bottom half so the + // hairline lands on the card's middle when there is one session (2 * the + // block is exactly two equal halves). A second session grows the sessions + // half past that, the block stays put at the bottom and the hairline drops + // with it. Both rules come from one number each, so nothing is pinned to an + // edge and no branch can put the sessions over the hairline. + implicitHeight: page.integratedSpend + ? Math.max(2 * card.spendBlockHeight, + body.implicitHeight + 2 * card.spendGap + card.spendBlockHeight) + : body.implicitHeight + 2 * page.pad + // The primary row sizes every card to the tallest one, so this body can + // have spare height. Centre it in the room it owns, which ends at the + // hairline when Spending shares the card. The block keeps the bottom edge + // and the spare splits above the sessions and above the hairline, so the + // content never drifts towards either edge. + readonly property int bodyTop: Math.round(Math.max(0, + card.height - body.implicitHeight + - (page.integratedSpend ? card.spendBlockHeight : 0)) / 2) + + Column { + id: body + x: page.pad + y: card.bodyTop + width: card.width - 2 * page.pad + spacing: page.step * 2 + + Item { + width: parent.width + height: Math.max(agentLine.implicitHeight, agoText.implicitHeight) + Row { + id: agentLine + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Math.round(5 * IrisStyle.density) + Rectangle { + id: liveDot + anchors.verticalCenter: parent.verticalCenter + width: Math.round(5 * IrisStyle.density) + height: width + radius: width / 2 + readonly property bool live: usage.runningSessions.length > 0 + // A live run breathes; an idle one sits still. + property real pulse: 1 + color: liveDot.live ? IrisStyle.success + : ColorUtils.applyAlpha(IrisStyle.accent, 0.45) + opacity: liveDot.live ? liveDot.pulse : 1 + // The pulse runs on its own clock. It used to be + // `running: liveDot.live`, and every cache refresh emptied + // the session list for a frame, which restarted the + // animation and made a live dot look faster than its + // 1.6 s cycle. A constant loop keeps the speed honest. + SequentialAnimation { + running: true + loops: Animation.Infinite + NumberAnimation { + target: liveDot + property: "pulse" + from: 0.35 + to: 1 + duration: 800 + easing.type: Easing.InOutQuad + } + NumberAnimation { + target: liveDot + property: "pulse" + from: 1 + to: 0.35 + duration: 800 + easing.type: Easing.InOutQuad + } + } + } + IrisText { + anchors.verticalCenter: parent.verticalCenter + text: "Sessions" + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + } + IrisText { + id: agoText + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: usage.relative(usage.lastActivityMs) + } + } + + // At most two live sessions; when none runs, say so. + Column { + width: parent.width + spacing: Math.round(2 * IrisStyle.density) + Repeater { + model: usage.runningSessions.slice(0, 2) + Column { + required property var modelData + width: parent.width + spacing: 0 + IrisText { + width: parent.width + text: modelData?.title ?? "" + font.pixelSize: Math.round(11 * IrisStyle.density) + elide: Text.ElideRight + maximumLineCount: 1 + } + IrisText { + width: parent.width + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(9 * IrisStyle.density) + elide: Text.ElideRight + maximumLineCount: 1 + text: page.elapsedLabel(modelData?.started ?? 0) + " · " + + usage.kmg(Number(modelData?.tokens ?? 0)) + " tokens" + } + } + } + IrisText { + width: parent.width + visible: usage.runningSessions.length === 0 + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "no session running" + } + } + } + + // Spending uses the empty room under the sessions instead of taking a row + // of its own, and it keeps the Spending card's own layout so the number + // reads the same on both agents. It sits on the card's bottom edge, but its + // own pad keeps the amount off that edge. + Item { + id: spendBlock + visible: page.integratedSpend + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.leftMargin: page.pad + anchors.rightMargin: page.pad + height: card.spendBlockHeight + + Rectangle { + id: spendHairline + width: parent.width + height: 1 + color: ColorUtils.applyAlpha(IrisStyle.muted, 0.16) + } + + AiUsageSpendBody { + id: spendBody + page: card.page + anchors.left: parent.left + anchors.right: parent.right + anchors.top: spendHairline.bottom + anchors.bottom: parent.bottom + anchors.topMargin: card.spendGap + anchors.bottomMargin: spendBody.padY + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendBody.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendBody.qml new file mode 100644 index 000000000..157518708 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendBody.qml @@ -0,0 +1,102 @@ +pragma ComponentBehavior: Bound + +// The Spending figures without a card around them. OpenCode shows them in the +// standalone Spend card; Claude and Codex show them inside the Now card, and +// both must read the same, so this lives in one place. +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +Item { + required property var page + id: body + // Vertical breathing room the figures want around them. The standalone + // Spend card pads its box with it, and the block inside Now keeps its + // bottom edge off the card with it, so the figures read the same in both + // places instead of the amount sitting on a card edge. + readonly property int padY: Math.round(14 * IrisStyle.density) + implicitHeight: column.implicitHeight + + Column { + id: column + width: body.width + spacing: body.page.step * 3 + + Item { + id: titleRow + width: parent.width + height: spendTitle.implicitHeight + IrisText { + id: spendTitle + anchors.left: parent.left + anchors.right: spendPeriod.left + anchors.rightMargin: Math.round(6 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + text: body.page.activeTool === "codex" ? "Est. Spending" : "Spending" + elide: Text.ElideRight + maximumLineCount: 1 + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + IrisText { + id: spendPeriod + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "This month" + (body.page.activeTool === "codex" + && (body.page.monthly.partial || body.page.monthly.stale) ? "*" : "") + } + MouseArea { + id: helpArea + anchors.fill: parent + hoverEnabled: true + acceptedButtons: Qt.NoButton + StyledToolTip { + extraVisibleCondition: helpArea.containsMouse + text: body.page.spendHelp + } + } + } + + Item { + width: parent.width + height: spendMetric.implicitHeight + Row { + id: spendRow + width: parent.width + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Math.round(6 * IrisStyle.density) + IrisText { + id: spendMetric + role: IrisText.Metric + text: body.page.monthly.available === false ? "—" + : body.page.money(body.page.monthly.current) + width: Math.min(implicitWidth, + Math.max(0, spendRow.width - spendMonth.implicitWidth - spendRow.spacing)) + fontSizeMode: Text.Fit + minimumPixelSize: Math.round(14 * IrisStyle.density) + font.pixelSize: Math.round(24 * IrisStyle.density) + } + IrisText { + id: spendMonth + anchors.baseline: spendMetric.baseline + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: body.page.monthly.label + } + } + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendCard.qml new file mode 100644 index 000000000..6d9cb48ea --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendCard.qml @@ -0,0 +1,40 @@ +pragma ComponentBehavior: Bound + +// The standalone Spending card OpenCode shows beside Now. The figures are the +// shared body, so this card is only the glass around them. +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +AiUsageGlassCard { + required property var usage + id: card + radius: page.radius + // padY, not the plain card pad: the figures need more room above and below + // than a label does, and a taller Now card beside this one must never + // squeeze the amount towards the bottom edge. + implicitHeight: body.implicitHeight + 2 * body.padY + // The primary row stretches this card to its neighbours' height. Centre the + // shared body instead of pinning it to the top, so the spare height splits + // evenly above and below the figures. + readonly property real spareHeight: card.height - card.implicitHeight + + AiUsageSpendBody { + id: body + page: card.page + anchors.left: parent.left + anchors.right: parent.right + anchors.leftMargin: page.pad + anchors.rightMargin: page.pad + height: implicitHeight + y: body.padY + Math.round(Math.max(0, card.spareHeight) / 2) + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageTrendCard.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageTrendCard.qml new file mode 100644 index 000000000..d963cae84 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageTrendCard.qml @@ -0,0 +1,189 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +AiUsageGlassCard { + required property var usage + signal periodMenuRequested(var trigger) + id: card + property bool expanded: false + radius: page.radius + implicitHeight: body.implicitHeight + 2 * page.pad + + readonly property string caption: { + const buckets = page.trendBuckets + if (buckets.length === 0) return "" + if (chart.hovered >= 0 && chart.hovered < buckets.length) { + const bucket = buckets[chart.hovered] + return page.bucketLabel(bucket) + " · " + usage.kmg(bucket.tokens) + " tokens" + } + const first = buckets.length > 0 ? page.bucketLabel(buckets[0]) : "" + const last = buckets.length > 0 ? page.bucketLabel(buckets[buckets.length - 1]) : "" + const range = first === last ? first : first + " – " + last + if (page.trend.unit === "hours") { + return range + " · " + usage.kmg(page.trend.total) + " tokens · peak " + + usage.kmg(page.trend.peak) + "/h" + } + const window = !page.hourly && page.periodDays === 0 && buckets.length === 45 + ? " · last 45 days" : "" + return range + " · " + usage.kmg(page.trend.total) + + " tokens · peak " + usage.kmg(page.trend.peak) + window + } + + Column { + id: body + x: page.pad + y: page.pad + width: card.width - 2 * page.pad + spacing: page.step * 2 + + AiUsageFoldHeader { + title: "Trend" + expanded: card.expanded + onToggled: card.expanded = !card.expanded + // A control at every width: collapsed it opens the card, expanded + // it opens the period menu. The hit area is padded out to a + // comfortable target instead of the label's implicit size. + Item { + id: periodText + width: periodLabelText.implicitWidth + height: periodLabelText.implicitHeight + IrisText { + id: periodLabelText + anchors.centerIn: parent + role: IrisText.Meta + color: card.expanded && periodArea.containsMouse + ? IrisStyle.accent : IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: page.periodLabel + } + MouseArea { + id: periodArea + anchors.fill: parent + anchors.margins: -Math.round(7 * IrisStyle.density) + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (!card.expanded) { + card.expanded = true + return + } + card.periodMenuRequested(periodText) + } + } + } + } + + Column { + id: content + width: parent.width + visible: card.expanded + spacing: page.step * 2 + + IrisText { + width: parent.width + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + elide: Text.ElideRight + maximumLineCount: 1 + text: card.caption + } + + Item { + id: chart + width: parent.width + height: Math.round(36 * IrisStyle.density) + property int hovered: -1 + readonly property int count: page.trendBuckets.length + readonly property int barGap: count > 30 + ? Math.round(1 * IrisStyle.density) : Math.round(2 * IrisStyle.density) + // Bars share the card width, so a wider page widens them and a + // narrow page keeps every bucket visible. + readonly property real slot: (chart.width - chart.barGap + * Math.max(0, count - 1)) / Math.max(1, count) + readonly property real step: chart.slot + chart.barGap + + Row { + id: barRow + anchors.fill: parent + spacing: chart.barGap + Repeater { + model: page.trendBuckets + Item { + id: barSlot + required property var modelData + required property int index + width: chart.slot + height: barRow.height + readonly property bool future: { + if (!page.hourly) return false + const nowHour = new Date() + nowHour.setMinutes(0, 0, 0) + return Number(modelData?.start ?? 0) > nowHour.getTime() + } + readonly property bool isToday: { + if (page.hourly) { + const nowHour = new Date() + nowHour.setMinutes(0, 0, 0) + return Number(modelData?.start ?? 0) === nowHour.getTime() + } + return String(modelData?.date ?? "") + === Qt.formatDate(new Date(), "yyyy-MM-dd") + } + readonly property real tokens: Number(modelData?.tokens ?? 0) + Rectangle { + visible: !barSlot.future + anchors.bottom: parent.bottom + anchors.horizontalCenter: parent.horizontalCenter + width: Math.max(1, parent.width - (parent.width > 6 + ? Math.round(2 * IrisStyle.density) : 0)) + height: barSlot.tokens > 0 + ? Math.max(2, (chart.height - 2) * barSlot.tokens / page.trend.peak) + : Math.max(2, Math.round(2 * IrisStyle.density)) + radius: Math.min(Math.round(2 * IrisStyle.density), width / 2) + color: barSlot.tokens <= 0 + ? ColorUtils.applyAlpha(IrisStyle.muted, 0.3) + : barSlot.isToday + ? IrisStyle.accent + : chart.hovered === barSlot.index + ? IrisStyle.accent + : ColorUtils.applyAlpha(IrisStyle.accent, 0.42) + } + } + } + } + + MouseArea { + id: hoverArea + anchors.fill: parent + hoverEnabled: true + onPositionChanged: function (mouse) { + // A page that opens under a still pointer gets a synthetic + // move; only a pointer actually inside the chart counts. + if (!hoverArea.containsMouse || chart.count === 0) return + const local = mouse.x - barRow.x + if (local < 0 || local > barRow.width) { + chart.hovered = -1 + return + } + chart.hovered = Math.max(0, Math.min(chart.count - 1, + Math.floor(local / chart.step))) + } + onExited: chart.hovered = -1 + } + } + + } + } +} diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageWindowRow.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageWindowRow.qml new file mode 100644 index 000000000..b3c69d1af --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageWindowRow.qml @@ -0,0 +1,88 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import QtQuick.Effects +import Quickshell +import Quickshell.Widgets +import inir +import inir.services +import inir.modules.common +import inir.modules.common.widgets +import inir.modules.common.functions +import inir.modules.iris.style +import inir.modules.iris.components + +Column { + id: windowRow + property string label: "" + property string caption: "" + property string share: "" + property real proportion: 0 + property string resetLabel: "" + width: parent ? parent.width : 0 + spacing: Math.round(3 * IrisStyle.density) + + readonly property color ink: windowRow.proportion >= 0.95 ? IrisStyle.danger + : windowRow.proportion >= 0.8 ? IrisStyle.warning + : IrisStyle.accent + + Item { + width: parent.width + height: Math.max(windowLabel.implicitHeight, windowShare.implicitHeight, + windowCaption.implicitHeight) + IrisText { + id: windowLabel + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Eyebrow + font.pixelSize: Math.round(10 * IrisStyle.density) + color: IrisStyle.muted + text: windowRow.label + } + IrisText { + id: windowCaption + anchors.left: windowLabel.right + anchors.leftMargin: Math.round(5 * IrisStyle.density) + anchors.right: windowShare.left + anchors.rightMargin: Math.round(6 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Eyebrow + font.pixelSize: Math.round(9 * IrisStyle.density) + color: IrisStyle.muted + elide: Text.ElideRight + text: windowRow.caption + } + IrisText { + id: windowShare + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Meta + font.pixelSize: Math.round(11 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + font.family: IrisStyle.fontNumbers + color: windowRow.proportion >= 0.8 ? windowRow.ink : IrisStyle.text + text: windowRow.share + } + } + Rectangle { + width: parent.width + height: Math.round(5 * IrisStyle.density) + radius: height / 2 + color: ColorUtils.applyAlpha(IrisStyle.muted, 0.22) + Rectangle { + height: parent.height + radius: parent.radius + width: Math.max(0, Math.min(1, windowRow.proportion)) * parent.width + color: windowRow.ink + } + } + IrisText { + visible: windowRow.resetLabel.length > 0 + width: parent.width + role: IrisText.Eyebrow + font.pixelSize: Math.round(9 * IrisStyle.density) + color: IrisStyle.muted + text: windowRow.resetLabel + } +} + diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json new file mode 100644 index 000000000..ba839c719 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json @@ -0,0 +1,90 @@ +{ + "name": "AI usage", + "icon": "neurology", + "version": "1.2.0", + "author": "Prasad Dhupkar ", + "description": "Agentic AI usage on the Shima Island: OpenCode sessions, period tokens, top models, month spending and an activity heatmap, with Codex from its own cache. Tap the agent chip to switch.", + "category": "system", + "main": "AiUsageTile.qml", + "iris": { + "main": "IrisCompact.qml", + "builtin": true, + "slots": [ + "island.desktop" + ] + }, + "defaultConfig": { + "placementStrategy": "free", + "widgetScale": 100, + "widgetOpacity": 100, + "colorMode": "auto", + "dim": 0, + "x": 240, + "y": 240 + }, + "configKeys": { + "tool": { + "type": "string", + "default": "opencode", + "label": "Which agent it watches", + "options": [ + { + "label": "OpenCode", + "value": "opencode" + }, + { + "label": "Codex", + "value": "codex" + } + ] + }, + "moduleWidth": { + "type": "int", + "default": 480, + "min": 280, + "max": 600, + "label": "Card width" + }, + "topModels": { + "type": "int", + "default": 3, + "min": 1, + "max": 3, + "label": "Top models to list" + }, + "period": { + "type": "string", + "default": "30", + "label": "Trend period", + "options": [ + { + "label": "Today", + "value": "today" + }, + { + "label": "Last 24 hours", + "value": "24h" + }, + { + "label": "Last 7 days", + "value": "7" + }, + { + "label": "Last 30 days", + "value": "30" + }, + { + "label": "All time", + "value": "all" + } + ] + } + }, + "resizableAxes": { + "uniform": "widgetScale" + }, + "defaultSize": { + "width": 380, + "height": 420 + } +} diff --git a/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml b/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml index a81af23a0..1bb80bba4 100644 --- a/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml +++ b/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml @@ -240,13 +240,6 @@ Item { onCanvasWidthChanged: Qt.callLater(root.clampToCanvas) onCanvasHeightChanged: Qt.callLater(root.clampToCanvas) - readonly property bool _exampleInstalled: { - if (!CustomWidgets.ready) return false; - for (let i = 0; i < CustomWidgets.widgets.length; i++) - if (CustomWidgets.widgets[i].id === "example-widget") return true; - return false; - } - // Block clicks from reaching desktop MouseArea { anchors.fill: parent; z: -1; acceptedButtons: Qt.AllButtons; propagateComposedEvents: false } @@ -717,16 +710,22 @@ Item { contentItem: MaterialSymbol { anchors.centerIn: parent; text: "folder_open"; iconSize: 16; color: root.ink } StyledToolTip { text: Translation.tr("Open widgets folder") } } - RippleButton { - visible: !root._exampleInstalled - width: 28; height: 28; buttonRadius: Appearance.rounding.full - colBackground: ColorUtils.applyAlpha(root.accentInk, 0.08) - colBackgroundHover: ColorUtils.applyAlpha(root.accentInk, 0.14) - colRipple: ColorUtils.applyAlpha(root.accentInk, 0.12) - releaseAction: () => { CustomWidgets.installExample(); CustomWidgets.reload() } - cancelAction: () => {} - contentItem: MaterialSymbol { anchors.centerIn: parent; text: "download"; iconSize: 16; color: root.accentInk } - StyledToolTip { text: Translation.tr("Install example widget") } + Repeater { + model: CustomWidgets.shippedWidgets + RippleButton { + required property var modelData + visible: !CustomWidgets.isInstalled(modelData.id) + width: visible ? 28 : 0 + height: 28 + buttonRadius: Appearance.rounding.full + colBackground: ColorUtils.applyAlpha(root.accentInk, 0.08) + colBackgroundHover: ColorUtils.applyAlpha(root.accentInk, 0.14) + colRipple: ColorUtils.applyAlpha(root.accentInk, 0.12) + releaseAction: () => CustomWidgets.installShipped(modelData.id) + cancelAction: () => {} + contentItem: MaterialSymbol { anchors.centerIn: parent; text: "download"; iconSize: 16; color: root.accentInk } + StyledToolTip { text: Translation.tr("Install %1").arg(modelData.name) } + } } } } diff --git a/ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml b/ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml index d16c28b33..1ed1bbb7a 100644 --- a/ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml +++ b/ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml @@ -9,7 +9,7 @@ Item { property string slot: "" property var targetScreen readonly property var entry: CustomWidgets.ready - ? (CustomWidgets.widgets.find(widget => widget.id === root.widgetId) ?? null) + ? (CustomWidgets.irisModules.find(widget => widget.id === root.widgetId) ?? null) : null readonly property string sourcePath: String(root.entry?.irisQmlPath ?? "") diff --git a/ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml b/ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml index 01a6f92d6..c1ebd6e7e 100644 --- a/ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml +++ b/ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml @@ -85,8 +85,7 @@ QtObject { // A choice row may carry a Ryoku-specific vocabulary (Ink/Wallpaper/Custom // for accent and highlight); fall back to the shared list. if (IrisStyle.ryokuFrontend && spec.ryokuChoices !== undefined) return spec.ryokuChoices - if (spec.choicesFrom === "irisModules") return (CustomWidgets.widgets ?? []) - .filter(widget => String(widget.irisQmlPath ?? "").length > 0) + if (spec.choicesFrom === "irisModules") return (CustomWidgets.irisModules ?? []) .map(widget => ({ label: String(widget.name ?? widget.id), value: "custom:" + widget.id })) return spec.choices ?? [] } @@ -331,7 +330,7 @@ QtObject { { target: "island", group: "Desktop page", label: "Header veil", description: "How much the wallpaper is dimmed behind the time. Below 100 % the time can be harder to read on bright wallpapers.", path: "iris.bar.desktopBannerVeil", visibleWhen: "iris.bar.desktopBanner=wallpaper", kind: "range", fallback: 100, min: 0, max: 100, step: 5, unit: " %", keywords: ["header", "banner", "dim", "dark", "veil", "scrim", "contrast", "wallpaper"] }, { target: "island", group: "Desktop page", label: "Header blur", description: "Softens the wallpaper behind the time.", path: "iris.bar.desktopBannerBlur", visibleWhen: "iris.bar.desktopBanner=wallpaper", kind: "range", fallback: 0, min: 0, max: 100, step: 5, unit: " %", keywords: ["header", "banner", "blur", "soft", "frost", "wallpaper"] }, { target: "island", group: "Desktop page", label: "Blocks", description: "What sits under the time, in the order you switch them on. You can also arrange them on the Island itself: open its desktop page and tap the pencil.", path: "iris.bar.desktopBlocks", kind: "pieces", fallback: ["profile", "context", "forecast", "agenda", "modules"], choices: [{label:"Profile",value:"profile"},{label:"Current app",value:"context"},{label:"Forecast",value:"forecast"},{label:"Up next",value:"agenda"},{label:"Vitals",value:"vitals"},{label:"Modules",value:"modules"}] }, - { target: "island", group: "Desktop page", label: "Your modules", description: "Modules written with the Shima SDK sit in the page's Modules block. Installed modules show up here.", path: "iris.bar.rightModules", kind: "pieces", fallback: [], choicesFrom: "irisModules", showIf: () => (CustomWidgets.widgets ?? []).some(widget => String(widget.irisQmlPath ?? "").length > 0), keywords: ["sdk", "custom", "module", "plugin", "extension", "widget"] }, + { target: "island", group: "Desktop page", label: "Your modules", description: "Modules sit in the page's Modules block. Built-in and installed modules show up here.", path: "iris.bar.rightModules", kind: "pieces", fallback: [], choicesFrom: "irisModules", showIf: () => (CustomWidgets.irisModules ?? []).length > 0, keywords: ["sdk", "custom", "module", "plugin", "extension", "widget"] }, { target: "island", group: "Desktop page", label: "Block style", description: "Plain rows sit on the Island's black like the rest of the page; Grouped puts the forecast and vitals strips on quiet plates.", path: "iris.bar.blockStyle", kind: "choice", fallback: "plain", choices: [{label:"Plain",value:"plain",glyph:"view_agenda"},{label:"Grouped",value:"grouped",glyph:"splitscreen"}] }, { target: "island", group: "Pages", label: "Page width", description: "How wide the open Island is. The activity page keeps its proportion.", path: "iris.bar.pageWidth", kind: "range", fallback: 440, min: 360, max: 600, step: 10, unit: " px" }, { target: "island", group: "Pages", label: "Navigation", description: "What the open Island's navigation row offers, in the order you switch them on. Pages come first; scroll over the row, or swipe sideways anywhere on the open Island, to move between them.", path: "iris.bar.navItems", kind: "pieces", fallback: ["media", "activity", "desktop", "tray", "tools", "focus", "today", "controls", "settings"], choices: [{label:"Now playing",value:"media"},{label:"Live activities",value:"activity"},{label:"Desktop",value:"desktop"},{label:"Tray",value:"tray"},{label:"Timers",value:"tools"},{label:"Focus panel",value:"focus"},{label:"Today panel",value:"today"},{label:"Quick controls",value:"controls"},{label:"Settings",value:"settings"}] }, diff --git a/ryoku/shell/quickshell/inir/services/CustomWidgets.qml b/ryoku/shell/quickshell/inir/services/CustomWidgets.qml index 36eeb8d18..87fdd8578 100644 --- a/ryoku/shell/quickshell/inir/services/CustomWidgets.qml +++ b/ryoku/shell/quickshell/inir/services/CustomWidgets.qml @@ -13,14 +13,30 @@ Singleton { // Discovered custom widgets: [{ id, name, icon, qmlPath, dirPath, ... }] property list widgets: [] + // Widgets the shell ships, offered for install: [{ id, name, icon, description }] + property list shippedWidgets: [] + // User copies take precedence; bundled modules remain available after updates. + readonly property var irisModules: { + const seen = new Set(); + return root.widgets.concat(root.shippedWidgets.filter(widget => widget.irisBuiltin)) + .filter(widget => { + if (seen.has(widget.id)) return false; + seen.add(widget.id); + return String(widget.irisQmlPath || "").length > 0; + }); + } readonly property bool ready: _scanDone readonly property string widgetsDir: `${Directories.configPath}/inir/widgets` + readonly property string shippedWidgetsDir: FileUtils.trimFileProtocol(Directories.payloadPath("defaults/widgets")) property bool _scanDone: false // Qt caches a component by URL for the engine lifetime. Reload through a fresh path so an updated widget compiles again. property int _generation: 0 - Component.onCompleted: _scan() + Component.onCompleted: { + _scan(); + _scanShipped(); + } Connections { target: Config function onReadyChanged() { @@ -38,6 +54,43 @@ Singleton { _scan(); } + function _scanShipped(): void { + _shippedScan.running = false; + _shippedScan.running = true; + } + + function _parseShipped(output: string): void { + try { + const entries = JSON.parse(output || "[]"); + const list = []; + for (const entry of entries) { + const manifest = entry.manifest || {}; + if (!manifest.name) + continue; + const iris = manifest.iris && typeof manifest.iris === "object" ? manifest.iris : {}; + const irisFile = typeof iris.main === "string" ? iris.main.trim() : ""; + list.push({ + id: entry.id, + name: manifest.name, + icon: manifest.icon || "widgets", + description: manifest.description || "", + irisBuiltin: iris.builtin === true, + irisQmlPath: irisFile.length > 0 ? `file://${entry.dir}/${irisFile}` : "" + }); + } + root.shippedWidgets = list; + } catch (e) { + console.warn("[CustomWidgets] Failed to parse shipped manifests:", e); + } + } + + function isInstalled(widgetId: string): bool { + for (let i = 0; i < root.widgets.length; i++) + if (root.widgets[i].id === widgetId) + return true; + return false; + } + function _scan(): void { _scanProcess.running = true; } @@ -211,9 +264,17 @@ Singleton { _removeProcess.running = true; } - // Install the built-in example widget + // Copy a shipped widget from defaults into the user's widgets directory + function installShipped(widgetId: string): void { + if (typeof widgetId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(widgetId) + || _installShippedProcess.running) return; + _installShippedProcess.widgetId = widgetId; + _installShippedProcess.running = true; + } + + // Keep the original entry point for callers that install the example. function installExample(): void { - _installExampleProcess.running = true; + root.installShipped("example-widget"); } // Open widget directory in file manager @@ -411,22 +472,39 @@ IRIS_QML } } - // Path to shipped example widget - readonly property string _exampleWidgetPath: FileUtils.trimFileProtocol(Directories.payloadPath("defaults/widgets/example-widget")) + // Shipped widget manifests, read from the same scanner as the user widget dir + Process { + id: _shippedScan + running: false + command: [Directories.scriptsPath + "/scan-widgets.sh", root.shippedWidgetsDir] + stdout: StdioCollector { + id: _shippedScanCollector + onStreamFinished: root._parseShipped(_shippedScanCollector.text ?? "") + } + } - // Copy example widget from defaults + // Copy a shipped widget from defaults Process { - id: _installExampleProcess + id: _installShippedProcess + property string widgetId: "" running: false command: ["bash", "-c", ` - src="${root._exampleWidgetPath}" - dest="${root.widgetsDir}/example-widget" + set -eu + src="$1/$3" + dest="$2/$3" [ -d "$src" ] || { echo "fail"; exit 1; } - [ -e "$dest" ] && { echo "exists"; exit 0; } - mkdir -p "$dest" - cp -r "$src"/* "$dest"/ - echo "done" - `] + if [ -e "$dest" ] || [ -L "$dest" ]; then + echo "exists" + exit 0 + fi + mkdir -p "$2" + staging=$(mktemp -d "$2/.install-$3.XXXXXX") + trap 'rm -rf -- "$staging"' EXIT + cp -r "$src"/. "$staging"/ + mv -Tn -- "$staging" "$dest" + if [ -d "$staging" ]; then echo "exists"; else echo "done"; fi + `, "install-shipped-widget", root.shippedWidgetsDir, root.widgetsDir, + _installShippedProcess.widgetId] stdout: StdioCollector { onStreamFinished: root.reload() } diff --git a/tests/ai-usage-cache.py b/tests/ai-usage-cache.py new file mode 100755 index 000000000..32dc3e799 --- /dev/null +++ b/tests/ai-usage-cache.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""End-to-end check of the AI usage cache the widget reads. + +Runs the repository collector against a temporary cache, then asserts the +contract IrisCompact.qml and the shell's own AI panel rely on. Exit code 0 = every field present and sane. + + python3 test/cache_contract.py +""" +import json +import os +import sqlite3 +import subprocess +import sys +import tempfile +from datetime import datetime, timedelta +from pathlib import Path + +COLLECTOR = Path(__file__).resolve().parent.parent / "ryoku" / "rashin" / "usage" / "opencode-usage" +WORK = tempfile.TemporaryDirectory(prefix="ai-usage-contract-") +CACHE = Path(WORK.name) / "usage.json" + +failures = [] + + +def check(condition, message): + if not condition: + failures.append(message) + + +def main(): + # A clean CI runner has no OpenCode account or store. Build the same cache + # contract from isolated message/session rows rather than the caller's data. + data_dir = Path(WORK.name) / "opencode" + data_dir.mkdir() + now_ms = int(datetime.now().timestamp() * 1000) + with sqlite3.connect(data_dir / "opencode.db") as db: + db.execute("create table session_v2 (id text primary key, title text, " + "directory text, time_created integer, time_updated integer, " + "time_idle integer, time_busy integer, tokens_input integer, " + "tokens_output integer, tokens_reasoning integer, " + "tokens_cache_read integer, tokens_cache_write integer, " + "cost real, model text)") + db.execute("create table session_message (id text primary key, session_id text, " + "type text, time_created integer, time_updated integer, data text)") + for index, age_ms in enumerate((3600000, 0)): + sid = f"contract-session-{index}" + stamp = now_ms - age_ms + model = {"id": f"contract-model-{index}", "providerID": "fixture"} + inp = 100 * (index + 1) + db.execute("insert into session_v2 values (?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + (sid, "Contract fixture", "/fixture", stamp, stamp, + stamp if index == 0 else 0, stamp - 1, inp, 20, 5, + 200, 10, 0.01, json.dumps(model))) + message = {"role": "assistant", "model": model, + "tokens": {"input": inp, "output": 20, "reasoning": 5, + "cache": {"read": 200, "write": 10}}, + "time": {"created": stamp, "completed": stamp}, "cost": 0.01} + db.execute("insert into session_message values (?,?,?,?,?,?)", + (f"message-{index}", sid, "assistant", stamp, stamp, + json.dumps(message))) + if COLLECTOR.exists(): + env = dict(os.environ, HOME=str(Path(WORK.name) / "home"), + OPENCODE_DATA_DIR=str(data_dir)) + subprocess.run([str(COLLECTOR), "--cache", str(CACHE)], check=True, env=env) + + check(CACHE.exists(), f"cache missing: {CACHE}") + if not CACHE.exists(): + return report() + + data = json.loads(CACHE.read_text()) + + # Keys the shipped shell panel still reads. + for key in ("5h-utilization", "7d-utilization", "_recent_days", "_models", + "_tokens_used", "_window_limit", "_today_tokens", "_7d_tokens"): + check(key in data, f"missing shell key: {key}") + + # Daily history for Trend / Activity. + days = data.get("_days", []) + check(isinstance(days, list) and len(days) > 0, "_days empty") + check(all({"date", "tokens", "cacheRead"} <= set(day) for day in days), + "_days rows need date+tokens+cacheRead") + check([day["date"] for day in days] == sorted(day["date"] for day in days), + "_days not oldest-to-newest") + check(days and days[-1]["date"] == datetime.now().strftime("%Y-%m-%d"), + "_days does not end today") + check(all(day["tokens"] >= 0 for day in days), "negative token count in _days") + check(all(day["cacheRead"] >= 0 for day in days), "negative cacheRead in _days") + + # Hourly history. + hours = data.get("_hours", []) + check(isinstance(hours, list) and len(hours) == 48, f"_hours should be 48, got {len(hours)}") + check(all({"start", "tokens", "cacheRead"} <= set(hour) for hour in hours), + "_hours rows need start+tokens+cacheRead") + check(all(hour["cacheRead"] >= 0 for hour in hours), "negative cacheRead in _hours") + if hours: + now_hour = datetime.now().replace(minute=0, second=0, microsecond=0) + check(abs(hours[-1]["start"] - int(now_hour.timestamp() * 1000)) < 60_000, + "_hours does not end in the current hour") + + # Period slices drive every card. + periods = data.get("_periods", {}) + for key in ("today", "24h", "7", "30", "all"): + period = periods.get(key) + check(period is not None, f"_periods missing {key}") + if not period: + continue + for field in ("tokens", "cacheRate", "cost", "sessions", "models"): + check(field in period, f"_periods[{key}] missing {field}") + check(0 <= period.get("cacheRate", 0) <= 1, f"_periods[{key}] cacheRate out of range") + check(period.get("tokens", 0) >= 0, f"_periods[{key}] negative tokens") + models = period.get("models", []) + check(len(models) <= 8, f"_periods[{key}] more than 8 models") + weights = [m["totalWithCacheRead"] for m in models] + check(weights == sorted(weights, reverse=True), + f"_periods[{key}] models not sorted by cache-inclusive total") + for model in models: + check({"name", "total", "totalLabel", "totalWithCacheRead", + "totalWithCacheReadLabel", "cacheRate", "pct"} <= set(model), + f"_periods[{key}] model row incomplete: {model.get('name')}") + check(model.get("totalWithCacheRead", 0) >= model.get("total", 0), + f"_periods[{key}] cache-inclusive total below total") + check(0 <= model.get("cacheRate", 0) <= 1, f"_periods[{key}] model cacheRate range") + check(0 <= model.get("pct", 0) <= 100, f"_periods[{key}] model pct range") + + # Windows must nest. + token = lambda key: (periods.get(key) or {}).get("tokens", 0) + check(token("today") <= token("24h"), "today larger than 24h") + check(token("7") <= token("30") <= token("all"), "7d/30d/all not nested") + + # Live sessions for the Now card. + sessions = data.get("_sessions", []) + check(isinstance(sessions, list) and len(sessions) > 0, "_sessions empty") + for session in sessions: + check({"id", "title", "started", "updated", "tokens", "running"} <= set(session), + f"session row incomplete: {session.get('id')}") + check(isinstance(session.get("running"), bool), "session.running is not a bool") + check(session.get("tokens", 0) >= 0, "session negative tokens") + if sessions: + check([s["updated"] for s in sessions] == sorted((s["updated"] for s in sessions), reverse=True), + "_sessions not newest-first") + + # Stale guard: the file must be fresher than a day. + updated = data.get("updated_at", "") + check(bool(updated), "missing updated_at") + + return report() + + +def report(): + if failures: + print("FAIL") + for failure in failures: + print(" -", failure) + return 1 + print("OK: cache contract holds") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ai-usage-widget.py b/tests/ai-usage-widget.py new file mode 100644 index 000000000..467d237d2 --- /dev/null +++ b/tests/ai-usage-widget.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Exercise the shipped QML expressions and compile real components in isolation.""" +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SHIPPED = ROOT / 'ryoku/shell/quickshell/inir/defaults/widgets/ai-usage' +WIDGET = SHIPPED if SHIPPED.exists() else ROOT / 'ai-usage' +IMPORTS = (ROOT / 'ryoku/shell/quickshell' if SHIPPED.exists() else + Path(os.environ.get('RYOKU_QML_IMPORTS', Path.home() / '.config/quickshell'))) + + +def function(source, name): + start = source.index(' function ' + name + '(') + end = source.index('\n }', start) + len('\n }') + body = source[start:end] + body = re.sub(r': (?:string|var|int|double|bool)(?=[,)])', '', body) + return re.sub(r'\): (?:string|var|int|double|bool|void) \{', ') {', body) + + +def binding(source, name): + match = re.search(r'^ readonly property \w+ ' + re.escape(name) + r': \{\n', + source, re.M) + if not match: + raise AssertionError('Missing binding: ' + name) + end = source.index('\n }', match.end()) + return source[match.end():end] + + +def node(script, timezone='Asia/Kolkata'): + result = subprocess.run(['node', '-e', script], capture_output=True, text=True, + env=dict(os.environ, TZ=timezone), timeout=15) + if result.returncode: + raise AssertionError(result.stdout + result.stderr) + + +SETUP = ''' +const assert = require('node:assert/strict'); +const RealDate = Date; +let clock = new RealDate(2026, 10, 7, 12).getTime(); +global.Date = class extends RealDate { + constructor(...args) { super(...(args.length ? args : [clock])); } + static now() { return clock; } +}; +const Qt = {formatDate: (d,format) => { + const value=[d.getFullYear(), String(d.getMonth()+1).padStart(2,'0'), + String(d.getDate()).padStart(2,'0')].join('-'); + return format==='yyyy-MM' ? value.slice(0,7) : value; +}}; +''' + + +class WidgetTest(unittest.TestCase): + def test_trends_and_heatmap_fixed_calendar_windows(self): + page = (WIDGET / 'IrisCompact.qml').read_text() + activity = (WIDGET / 'components/AiUsageActivityCard.qml').read_text() + script = SETUP + function(page, 'historyWeight') + '\n' + function(activity, 'dayWeight') + script += '\nfunction trend() {\n' + binding(page, 'trend') + '\n}' + script += '\nfunction heatmap() {\n' + binding(activity, 'map') + '\n}' + script += ''' +const usageModel = {days:[], hours:[]}, usage = usageModel; +const root = {hourly:false, periodDays:7, historyWeight}; +const card = {dayWeight}; +for (const date of [[2026,2,8],[2026,10,1],[2026,10,7]]) { + clock = new RealDate(...date, 12).getTime(); + for (let weekday=0; weekday<7; weekday++) { + clock += 86400000; + const today = new Date(); + const yesterday = new Date(clock); yesterday.setDate(yesterday.getDate()-1); + usage.days = [{date:Qt.formatDate(yesterday),tokens:2,cacheRead:3}, + {date:Qt.formatDate(today),tokens:7,cacheRead:11}]; + for (const count of [7,30,45]) { + root.periodDays = count === 45 ? 0 : count; + const result = trend(); + assert.equal(result.buckets.length,count); + assert.equal(result.buckets.at(-1).date,Qt.formatDate(today)); + assert.equal(result.total,23); + assert.equal(result.buckets.filter(x=>x.tokens===0).length,count-2); + } + const map = heatmap(); + assert.equal(map.weeks.length,9); + assert(map.weeks.every(week=>week.length===7 && week.every(Boolean))); + const cells=map.weeks.flat(); + assert.equal(new Set(cells.map(x=>x.date)).size,63); + assert.equal(cells.at(-1).date,Qt.formatDate(today)); + const start=new Date(clock);start.setDate(start.getDate()-62); + assert.equal(cells[0].date,Qt.formatDate(start)); + assert.equal(cells.filter(x=>x.value===0).length,61); + assert.equal(map.active,2); + } +} +usage.days=[]; +assert.deepEqual(heatmap().weeks,[]); +''' + for timezone in ('Asia/Kolkata', 'America/New_York'): + node(script, timezone) + + def test_cache_hours_normalize_both_units(self): + source = (WIDGET / 'UsageModel.qml').read_text() + names = ('empty', 'pct', 'normalizeWindows', 'shortName', 'normalizePeriods', 'kmg', 'parse') + script = SETUP + '\n'.join(function(source, name) for name in names) + script += '\nconst root = {' + ','.join(names) + '}; const switchTimer={stop(){}};' + script += ''' +for (const start of [1794054600,1794054600000]) { + root.parse(JSON.stringify({_hours:[{start,tokens:10,cacheRead:20}]}),clock/1000); + assert.equal(root.hours[0].start,1794054600000); + assert.equal(root.hours[0].tokens+root.hours[0].cacheRead,30); +} +''' + node(script) + + def test_agent_fallback_uses_only_supported_available_tools(self): + source = (WIDGET / 'IrisCompact.qml').read_text() + script = SETUP + '\nfunction activeTool() {\n' + binding(source, 'activeTool') + '\n}' + script += ''' +const availability={ready:true}; +const root={tool:'claude',hasAnyAgent:false,toolAvailable:false,agentOptions:[]}; +for (const open of [false,true]) for (const codex of [false,true]) { + root.agentOptions=[...(open?[{value:'opencode'}]:[]),...(codex?[{value:'codex'}]:[])]; + root.hasAnyAgent=root.agentOptions.length>0; + for (const tool of ['claude','opencode','codex']) { + root.tool=tool; + root.toolAvailable=root.agentOptions.some(x=>x.value===tool); + const expected=root.toolAvailable?tool:root.agentOptions[0]?.value??'opencode'; + assert.equal(activeTool(),expected); + } +} +''' + node(script) + + def test_tile_reads_its_model_and_actual_quota_window(self): + source = (WIDGET / 'AiUsageTile.qml').read_text() + expression = re.search(r' StyledText \{\n text: (.*?)\n font \{', + source, re.S).group(1) + script = SETUP + '\nfunction metric() { return (' + expression + '); }' + script += ''' +const root={}; +const usage={has:false,hasPlan:false,windows:[],today:123,kmg:n=>String(n)}; +assert.equal(metric(),'—'); +usage.has=true; +assert.equal(metric(),'123 tokens today'); +usage.hasPlan=true; +usage.windows=[{pct:48,label:'30d'}]; +assert.equal(metric(),'48% · 30d'); +usage.windows=[]; +assert.equal(metric(),'123 tokens today'); +''' + node(script) + + def test_packaged_collector_lookup_preserves_environment_override(self): + source = (WIDGET / 'UsageModel.qml').read_text() + expression = re.search( + r' readonly property string collectorPath: ([\s\S]+?)\n readonly property var collectorCommand:', + source).group(1).strip() + script = '''const assert=require('node:assert/strict'); +let override=''; +const Quickshell={env:name=>name==='RYOKU_AI_USAGE_COLLECTOR'?override:'/fixture/home'}; +const collectorPath=()=>''' + expression + '''; +assert.equal(collectorPath(),'opencode-usage'); +override='/fixture/custom-collector'; +assert.equal(collectorPath(),override); +''' + node(script) + + def test_estimated_spending_is_codex_only_and_checks_month_and_coverage(self): + source = (WIDGET / 'IrisCompact.qml').read_text() + script = SETUP + '\nfunction monthly() {\n' + binding(source, 'monthly') + '\n}' + script += '\nfunction spendHelp() {\n' + binding(source, 'spendHelp') + '\n}' + script += ''' +const root={activeTool:'codex',monthLabel:()=>'Nov'}; +const usageModel={plan:'plus',days:[{date:'2026-11-07',cost:99}],spending:{ + kind:'api-equivalent',month:'2026-11',amount:1.23,partial:true,price_date:'2026-11-07'}}; +root.monthly=monthly(); +assert.equal(root.monthly.current,1.23); +assert.equal(root.monthly.available,true); +assert.equal(root.monthly.partial,true); +assert(spendHelp().includes('Not a bill')); +assert(spendHelp().includes('Plus')); +usageModel.spending.amount=null; +assert.equal(monthly().available,false); +usageModel.spending.amount=NaN; +assert.equal(monthly().available,false); +usageModel.spending.amount=1.23; +usageModel.spending.month='2026-10'; +assert.equal(monthly().available,false); +root.activeTool='opencode'; +assert.equal(monthly().current,99); +assert.equal(monthly().available,undefined); +''' + node(script) + + @unittest.skipUnless(shutil.which('qs') and (IMPORTS / 'inir').exists(), + 'Quickshell and Ryoku imports required for component compilation') + def test_real_qml_components_compile_without_starting_services(self): + with tempfile.TemporaryDirectory(prefix='ai-usage-qml-') as directory: + work = Path(directory) + for name in ('home', 'config', 'cache', 'runtime'): + (work / name).mkdir(mode=0o700) + for name in ('inir', 'shell', 'stage'): + if (IMPORTS / name).exists(): + (work / name).symlink_to(IMPORTS / name, target_is_directory=True) + paths = [str(IMPORTS), str(Path.home() / '.local/lib/qt6/qml')] + if SHIPPED.exists(): + (work / 'Ryoku').mkdir() + (work / 'Ryoku/Ui').symlink_to(ROOT / 'ryoku/ui', target_is_directory=True) + targets = list(WIDGET.rglob('*.qml')) + platform = os.environ.get('AI_USAGE_QML_PLATFORM', 'offscreen') + if SHIPPED.exists(): + targets.append(ROOT / 'ryoku/shell/quickshell/inir/services/CustomWidgets.qml') + targets.append(ROOT / 'ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml') + targets.append(ROOT / 'ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml') + if platform == 'wayland': + targets.append(ROOT / 'ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml') + uris = json.dumps([path.as_uri() for path in targets]) + (work / 'shell.qml').write_text('''import QtQuick +import Quickshell +ShellRoot { + Timer { interval: 50; running: true; onTriggered: { + let failures=0; + for (const path of ''' + uris + ''') { + const component=Qt.createComponent(path, Component.PreferSynchronous); + if (component.status!==Component.Ready) { + console.log('FAIL:',path,component.errorString()); failures++; + } + } + console.log(failures ? 'AI-USAGE-COMPILE-FAIL' : 'AI-USAGE-COMPILE-PASS'); + Qt.quit(); + } } +}''') + env = dict(os.environ, HOME=str(work / 'home'), XDG_CONFIG_HOME=str(work / 'config'), + XDG_CACHE_HOME=str(work / 'cache'), XDG_STATE_HOME=str(work / 'state'), + XDG_RUNTIME_DIR=str(work / 'runtime'), QT_QPA_PLATFORM=platform, + QML_IMPORT_PATH=os.pathsep.join([str(work)] + paths), + QML2_IMPORT_PATH=os.pathsep.join([str(work)] + paths), + QS_DISABLE_CRASH_HANDLER='1') + if platform == 'wayland': + display = Path(os.environ.get('WAYLAND_DISPLAY', 'wayland-0')) + if not display.is_absolute(): + display = Path(os.environ['XDG_RUNTIME_DIR']) / display + env['WAYLAND_DISPLAY'] = str(display) + result = subprocess.run(['qs', '-p', str(work / 'shell.qml')], env=env, + capture_output=True, text=True, timeout=20) + output = result.stdout + result.stderr + self.assertEqual(result.returncode, 0, output) + self.assertIn('AI-USAGE-COMPILE-PASS', output) + self.assertNotIn('FAIL:', output) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/codex-usage-regression.py b/tests/codex-usage-regression.py new file mode 100644 index 000000000..c8a3f4988 --- /dev/null +++ b/tests/codex-usage-regression.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 +"""Regression cases for Codex rollout replay and account quota windows.""" +import contextlib +from datetime import datetime +import io +import json +import os +from pathlib import Path +import re +import runpy +import signal +import subprocess +import sys +import tempfile +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +COLLECTOR = Path(os.environ.get('CODEX_USAGE_TEST_COLLECTOR', ROOT / 'ryoku/rashin/usage/codex-usage')) + + +class CodexUsageTest(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix='codex-usage-test-') + self.addCleanup(self.temp.cleanup) + self.home = Path(self.temp.name) + self.sessions = self.home / 'codex/sessions' + self.archive = self.home / 'codex/archived_sessions' + self.sessions.mkdir(parents=True) + self.archive.mkdir() + self.path = self.sessions / 'rollout-test.jsonl' + self.cache = self.home / 'usage.json' + self.activity = self.home / 'activity.json' + self.now = int(time.time()) + self.env = dict(os.environ, HOME=str(self.home), CODEX_HOME=str(self.sessions.parent), + CODEX_USAGE_DISABLE_RPC='1', CODEX_USAGE_NOW=str(self.now), + CODEX_USAGE_CACHE_FILE=str(self.cache), + CODEX_USAGE_ACTIVITY_FILE=str(self.activity), + CODEX_USAGE_STATE_DIR=str(self.home / 'spending'), + CODEX_USAGE_DISABLE_PRICES='1') + self.set_limits('plus', 300, 10080) + self.records = [self.meta('one'), self.event(1000, 200, 300, -120), + self.event(3000, 900, 1200, -60)] + self.write(self.path, self.records) + + def stamp(self, offset): + return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(self.now + offset)) + + def meta(self, sid): + return {'timestamp': self.stamp(-180), 'type': 'session_meta', + 'payload': {'id': sid, 'timestamp': self.stamp(-180), 'cwd': '/test'}} + + def event(self, total, cached, output, offset): + return {'timestamp': self.stamp(offset), 'type': 'event_msg', + 'payload': {'type': 'token_count', 'info': {'total_token_usage': { + 'input_tokens': total - output, 'output_tokens': output, + 'cached_input_tokens': cached, 'total_tokens': total}}}} + + def write(self, path, records): + path.write_text(''.join(json.dumps(row) + '\n' for row in records)) + + def set_limits(self, plan, primary, secondary=None): + def window(minutes): + return {'usedPercent': 25, 'windowDurationMins': minutes, + 'resetsAt': self.now + minutes * 60} if minutes else None + self.env['CODEX_USAGE_RATE_LIMITS_JSON'] = json.dumps({ + 'source': 'rpc', 'rateLimits': {'limitId': 'codex', 'planType': plan, + 'primary': window(primary), 'secondary': window(secondary)}}) + + def collect(self): + subprocess.run(['python3', str(COLLECTOR)], env=self.env, check=True, + capture_output=True, text=True, timeout=10) + return json.loads(self.cache.read_text()) + + def assert_totals(self, cache, inclusive=3000, uncached=2100): + self.assertEqual(cache['_today_tokens'], inclusive) + self.assertEqual(cache['_rate_per_hour'], inclusive) + self.assertEqual(sum(x['tokens'] for x in cache['_recent_days']), inclusive) + self.assertEqual(cache['_periods']['all']['tokens'], uncached) + self.assertEqual(sum(x['tokens'] for x in cache['_sessions']), uncached) + self.assertEqual(sum(x['tokens'] + x['cacheRead'] for x in cache['_days']), inclusive) + self.assertEqual(sum(x['tokens'] + x['cacheRead'] for x in cache['_hours']), inclusive) + + def test_repeat_snapshot_and_unchanged_refresh(self): + self.write(self.path, self.records + [self.event(3000, 900, 1200, -50)]) + self.assert_totals(self.collect()) + self.assert_totals(self.collect()) + + def test_archive_move_and_new_append(self): + self.assert_totals(self.collect()) + target = self.archive / self.path.name + self.path.rename(target) + self.assert_totals(self.collect()) + self.write(target, self.records + [self.event(4000, 1200, 1600, -10)]) + self.assert_totals(self.collect(), 4000, 2800) + + def test_truncation_then_replay_and_append(self): + self.assert_totals(self.collect()) + self.write(self.path, self.records[:2]) + cache = self.collect() + self.assertEqual(cache['_today_tokens'], 3000) + self.assertEqual(cache['_recent_days'][-1]['tokens'], 3000) + self.write(self.path, self.records + [self.event(4000, 1200, 1600, -10)]) + self.assert_totals(self.collect(), 4000, 2800) + + def test_duplicate_rollout_and_shared_prefix(self): + self.write(self.archive / self.path.name, self.records) + self.assert_totals(self.collect()) + self.write(self.path, self.records + [self.event(4000, 1200, 1600, -10)]) + self.assert_totals(self.collect(), 4000, 2800) + + def test_separate_sessions_with_equal_counters_are_distinct(self): + self.write(self.archive / 'second.jsonl', [self.meta('two')] + self.records[1:]) + self.assert_totals(self.collect(), 6000, 4200) + + def test_counter_reset_is_new_usage(self): + self.write(self.path, self.records + [self.event(500, 100, 200, -10)]) + self.assert_totals(self.collect(), 3500, 2500) + + def test_schema_three_state_is_rebuilt_without_replay_inflation(self): + self.activity.write_text(json.dumps({'schemaVersion': 3, + 'files': {}, 'events': [[self.now - 60, 9000]], 'dailyTokens': {}})) + self.assert_totals(self.collect()) + self.assert_totals(self.collect()) + self.assertEqual(json.loads(self.activity.read_text())['schemaVersion'], 4) + + def test_yesterday_replay_after_hour_events_are_pruned(self): + self.collect() + self.env['CODEX_USAGE_NOW'] = str(self.now + 86400) + self.collect() + self.path.rename(self.archive / self.path.name) + os.utime(self.archive / self.path.name, (self.now + 86400, self.now + 86400)) + cache = self.collect() + self.assertEqual(sum(x['tokens'] for x in cache['_recent_days']), 3000) + self.assertEqual(cache['_today_tokens'], 0) + + def test_plus_windows_use_minutes_percent_and_unix_seconds(self): + cache = self.collect() + self.assertEqual([x['minutes'] for x in cache['_windows']], [300, 10080]) + self.assertEqual([x['utilization'] for x in cache['_windows']], [0.25, 0.25]) + self.assertEqual(cache['_windows'][0]['reset'], self.now + 300 * 60) + self.assertEqual(cache['5h-utilization'], '0.2500') + self.assertEqual(cache['7d-utilization'], '0.2500') + + def test_free_window_does_not_restore_old_paid_windows(self): + self.records[-1]['payload']['rate_limits'] = { + 'limit_id': 'codex', 'plan_type': 'plus', + 'primary': {'used_percent': 20, 'window_minutes': 300, 'resets_at': self.now + 3000}, + 'secondary': {'used_percent': 10, 'window_minutes': 10080, 'resets_at': self.now + 90000}} + self.write(self.path, self.records) + self.collect() + self.set_limits('free', 43200) + cache = self.collect() + self.assertEqual(cache['_plan'], 'free') + self.assertEqual([x['minutes'] for x in cache['_windows']], [43200]) + self.assertEqual(cache['_windows'][0]['label'], '30d') + self.assertEqual(cache['5h-utilization'], '') + self.assertEqual(cache['7d-utilization'], '') + + def test_weekly_only_plan_does_not_gain_five_hour_window(self): + self.set_limits('pro', None, 10080) + cache = self.collect() + self.assertEqual([x['minutes'] for x in cache['_windows']], [10080]) + self.assertEqual(cache['5h-utilization'], '') + + def test_rollout_fallback_free_window(self): + self.env.pop('CODEX_USAGE_RATE_LIMITS_JSON') + self.records[-1]['payload']['rate_limits'] = { + 'limit_id': 'codex', 'plan_type': 'free', + 'primary': {'used_percent': 48, 'window_minutes': 43200, + 'resets_at': self.now + 2500000}} + self.write(self.path, self.records) + cache = self.collect() + self.assertEqual(cache['_source'], 'rollout') + self.assertEqual([x['minutes'] for x in cache['_windows']], [43200]) + self.assertEqual(cache['_windows'][0]['utilization'], 0.48) + + def test_rollout_fallback_ignores_previous_plan_windows(self): + paid = dict(self.records[1]) + paid['payload'] = dict(paid['payload'], rate_limits={ + 'limit_id': 'codex', 'plan_type': 'plus', + 'primary': {'used_percent': 20, 'window_minutes': 300, 'resets_at': self.now + 3000}, + 'secondary': {'used_percent': 10, 'window_minutes': 10080, 'resets_at': self.now + 90000}}) + self.records[1] = paid + self.test_rollout_fallback_free_window() + + def test_actual_qml_window_parser_and_reset_countdown(self): + # Execute the shipped function bodies rather than a Python translation. + source = (ROOT / 'ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml').read_text() + functions = [] + for name in ('pct', 'normalizeWindows', 'resetIn'): + start = source.index(' function ' + name + '(') + end = source.index('\n }', start) + len('\n }') + function = source[start:end] + function = re.sub(r': (?:string|var|int|double)(?=[,)])', '', function) + function = re.sub(r'\): (?:string|var|int) \{', ') {', function) + functions.append(function) + script = '\n'.join(functions) + ''' +const assert = require('node:assert/strict'); +const root = {pct, tick: 0, windowTokens: '', weekTokens: '', pct5h: 25, percent7d: 10, + reset5hTs: 123, reset7dTs: 456}; +const free = normalizeWindows({_windows: [{minutes:43200, utilization:0.48, reset:12345}]}); +assert.deepEqual(free, [{label:'30d', caption:'', pct:48, resetTs:12345}]); +assert.deepEqual(normalizeWindows({_windows: []}), []); +assert.deepEqual(normalizeWindows({_plan:'free'}), []); +assert.deepEqual(normalizeWindows({_windows: [{minutes:300, utilization:0.25}, + {minutes:10080, utilization:0.1}]}).map(x=>[x.label,x.pct]), [['5h',25],['7d',10]]); +assert.deepEqual(normalizeWindows({_windows:[{minutes:60,utilization:0}]}).map(x=>x.label), ['1h']); +assert.deepEqual(normalizeWindows({'5h-utilization':'0','7d-utilization':'0.1'}).map(x=>x.label), ['5h','7d']); +Date.now = () => 1000000; +assert.equal(resetIn(0), ''); +assert.equal(resetIn(999), 'reset due'); +assert.equal(resetIn(1060), 'resets in 1 min'); +assert.equal(resetIn(1000+30*86400), 'resets in 30 days'); +''' + subprocess.run(['node', '-e', script], check=True, capture_output=True, text=True) + + def rpc_fixture(self, mode='ok'): + tools = self.home / 'rpc-bin' + tools.mkdir(exist_ok=True) + fake = tools / 'codex' + fake.write_text('''#!/usr/bin/env python3 +import json,os,sys +from pathlib import Path +trace=Path(os.environ['RPC_TRACE']) +record={'pid':os.getpid(),'args':sys.argv[1:],'methods':[]} +trace.write_text(json.dumps(record)) +for line in sys.stdin: + request=json.loads(line) + method=request['method'] + record['methods'].append(method) + trace.write_text(json.dumps(record)) + if 'id' not in request: continue + mode=os.environ.get('RPC_TEST_MODE') + if mode=='initialize_eof' and method=='initialize': sys.exit(0) + if (mode=='initialize_error' and method=='initialize') or (mode=='limits_error' and method=='account/rateLimits/read'): + reply={'id':request['id'],'error':{'code':-1,'message':os.environ['OPENAI_API_KEY']}} + elif method=='account/read': + reply={'id':request['id'],'result':{'account':{'type':os.environ.get('RPC_ACCOUNT_TYPE','chatgpt'),'planType':'plus', + 'email':'private@example.invalid','access_token':os.environ['OPENAI_API_KEY']}}} + elif method=='account/rateLimits/read': + reply={'id':request['id'],'result':{'rateLimits':{'limitId':'codex','planType':'plus', + 'primary':{'usedPercent':25,'windowDurationMins':300, + 'resetsAt':int(os.environ['CODEX_USAGE_NOW'])+18000}, + 'access_token':os.environ['OPENAI_API_KEY']}}} + else: reply={'id':request['id'],'result':{}} + print(json.dumps(reply),flush=True) +''') + fake.chmod(0o755) + env = dict(self.env, PATH=str(tools) + os.pathsep + os.environ['PATH'], + RPC_TRACE=str(self.home / 'rpc-trace.json'), RPC_TEST_MODE=mode, + OPENAI_API_KEY='FAKE-CREDENTIAL-MUST-NOT-LEAK') + env.pop('CODEX_USAGE_DISABLE_RPC', None) + env.pop('CODEX_USAGE_RATE_LIMITS_JSON', None) + return env + + def test_rpc_uses_private_stdio_and_publishes_no_credentials(self): + self.env = self.rpc_fixture() + data = self.collect() + trace = json.loads((self.home / 'rpc-trace.json').read_text()) + self.assertEqual(trace['methods'], ['initialize', 'initialized', 'account/read', + 'account/rateLimits/read']) + self.assertEqual(trace['args'][-2:], ['--listen', 'stdio://']) + self.assertNotIn(self.env['OPENAI_API_KEY'], json.dumps(trace)) + self.assertNotIn(self.env['OPENAI_API_KEY'], self.cache.read_text()) + self.assertNotIn('private@example.invalid', self.cache.read_text()) + self.assertEqual(data['_source'], 'rpc') + self.assertEqual(data['_windows'][0]['utilization'], 0.25) + + def test_rpc_failure_reaps_child_and_redacts_provider_errors(self): + module = runpy.run_path(str(COLLECTOR)) + namespace = module['fetch_rpc'].__globals__ + for mode in ('initialize_error', 'initialize_eof', 'limits_error'): + with self.subTest(mode=mode): + env = self.rpc_fixture(mode) + namespace['ENV'] = env + errors = io.StringIO() + with patch.dict(os.environ, {'CODEX_USAGE_DISABLE_RPC': '0'}), contextlib.redirect_stderr(errors): + self.assertIsNone(module['fetch_rpc']()) + self.assertNotIn(env['OPENAI_API_KEY'], errors.getvalue()) + trace = json.loads((self.home / 'rpc-trace.json').read_text()) + try: + os.kill(trace['pid'], 0) + except ProcessLookupError: + continue + # A regression must not leave the test's fake server behind. + os.kill(trace['pid'], signal.SIGKILL) + os.waitpid(trace['pid'], 0) + self.fail('RPC child survived a failed request') + + def test_api_key_and_bedrock_do_not_reuse_old_chatgpt_limits(self): + baseline = dict(self.env) + self.records[-1]['payload']['rate_limits'] = { + 'limit_id': 'codex', 'plan_type': 'plus', + 'primary': {'used_percent': 48, 'window_minutes': 300, + 'resets_at': self.now + 3000}} + self.write(self.path, self.records) + for account_type in ('apiKey', 'amazonBedrock'): + with self.subTest(account_type=account_type): + self.env = dict(baseline) + self.assertTrue(self.collect()['_windows']) + self.env = self.rpc_fixture('limits_error') + self.env['RPC_ACCOUNT_TYPE'] = account_type + data = self.collect() + self.assertEqual(data['_windows'], []) + self.assertEqual(data['buckets'], []) + self.assertEqual(data['_plan'], '') + self.assertEqual(data['5h-utilization'], '') + self.assertEqual(data['7d-utilization'], '') + self.assertEqual(data['_source'], 'rpc') + self.assert_totals(data) + self.assertEqual(data['_spending']['kind'], 'api-equivalent') + trace = json.loads((self.home / 'rpc-trace.json').read_text()) + self.assertEqual(trace['methods'], ['initialize', 'initialized', 'account/read']) + self.assertNotIn(self.env['OPENAI_API_KEY'], self.cache.read_text()) + + def test_rpc_partial_json_line_cannot_bypass_timeout(self): + script = ''' +import io,os,runpy,sys +from types import SimpleNamespace +module=runpy.run_path(sys.argv[1]) +read,write=os.pipe() +with os.fdopen(read,'rb',buffering=0) as stream: + os.write(write,b'{"id":') + try: + module['rpc_request'](SimpleNamespace(stdin=io.StringIO(),stdout=stream),1,'initialize',timeout=0.05) + except TimeoutError: + pass + else: + raise AssertionError('partial JSON escaped timeout') + finally: + os.close(write) +''' + result = subprocess.run([sys.executable, '-c', script, str(COLLECTOR)], + capture_output=True, text=True, timeout=2) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_rpc_preserves_buffered_frames_between_requests(self): + from types import SimpleNamespace + module = runpy.run_path(str(COLLECTOR)) + with tempfile.TemporaryFile() as stream: + stream.write(b'{"id":1,"result":{"ok":1}}\n{"id":2,"result":{"ok":2}}\n') + stream.seek(0) + proc = SimpleNamespace(stdin=io.StringIO(), stdout=stream) + for index in (1, 2): + self.assertEqual(module['rpc_request'](proc, index, 'initialize')['result']['ok'], index) + + def test_rpc_bounds_response_size(self): + from types import SimpleNamespace + module = runpy.run_path(str(COLLECTOR)) + with tempfile.TemporaryFile() as stream: + stream.write(b'x' * (1024 * 1024 + 1)) + stream.seek(0) + proc = SimpleNamespace(stdin=io.StringIO(), stdout=stream) + with self.assertRaisesRegex(RuntimeError, 'size limit'): + module['rpc_request'](proc, 1, 'initialize') + + def test_usage_and_activity_cache_permissions_are_private(self): + module = runpy.run_path(str(COLLECTOR)) + for path in (self.cache, self.activity): + path.write_text('{}') + path.chmod(0o644) + old_umask = os.umask(0o022) + try: + module['atomic_write_json'](path, {'safe': True}) + finally: + os.umask(old_umask) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertEqual(json.loads(path.read_text()), {'safe': True}) + + def test_failed_cache_publish_preserves_previous_file(self): + module = runpy.run_path(str(COLLECTOR)) + self.cache.write_text('{"previous":true}') + with patch.object(os, 'replace', side_effect=OSError('fixture failure')): + with self.assertRaises(OSError): + module['atomic_write_json'](self.cache, {'replacement': True}) + self.assertEqual(json.loads(self.cache.read_text()), {'previous': True}) + self.assertEqual(list(self.home.glob('.usage.json.*')), []) + + def spending_fixture(self): + self.now = int(datetime.now().replace(day=7, hour=12, minute=0, second=0, microsecond=0).timestamp()) + self.env['CODEX_USAGE_NOW'] = str(self.now) + self.set_limits('plus', 300, 10080) + self.records = [self.meta('one'), {'type': 'turn_context', 'payload': {'model': 'gpt-test'}}, + self.event(1000, 200, 300, -120), self.event(3000, 900, 1200, -60)] + self.write(self.path, self.records) + self.set_prices() + + def set_prices(self, multiplier=1, **extra): + rates = dict(input=2*multiplier, output=10*multiplier, + cache_read=0.1*multiplier, cache_write=2.5*multiplier) + rates.update(extra) + self.env['CODEX_USAGE_PRICES_JSON'] = json.dumps({'openai': {'models': { + 'gpt-test': {'cost': rates}}}}) + + def ledger(self): + month = datetime.fromtimestamp(int(self.env['CODEX_USAGE_NOW'])).strftime('%Y-%m') + return json.loads((self.home / 'spending' / f'spending-{month}.json').read_text()) + + def test_spending_daily_snapshot_deduplicates_and_preserves_seen_usage(self): + self.spending_fixture() + first = self.collect()['_spending'] + self.assertAlmostEqual(first['amount'], 0.01389) + self.assertEqual(first['priced_tokens'], 3000) + self.assertFalse(first['partial']) + self.write(self.archive / self.path.name, self.records) + self.set_prices(multiplier=10) + second = self.collect()['_spending'] + self.assertEqual(first, second) + self.assertEqual(len(self.ledger()['events']), 2) + self.path.unlink() + (self.archive / self.path.name).unlink() + self.assertEqual(self.collect()['_spending']['amount'], first['amount']) + self.assertEqual(next(iter(self.ledger()['days'].values()))['prices']['gpt-test']['input'], 2) + + def test_spending_rollover_uses_new_prices_and_accepts_late_old_usage(self): + self.spending_fixture() + self.collect() + first_day = datetime.fromtimestamp(self.now).strftime('%Y-%m-%d') + self.now += 86400 + self.env['CODEX_USAGE_NOW'] = str(self.now) + self.set_prices(multiplier=10) + self.records.append(self.event(5000, 1500, 2000, -60)) + self.write(self.path, self.records) + estimate = self.collect()['_spending'] + self.assertAlmostEqual(estimate['amount'], 0.10649) + self.assertEqual(len(estimate['priced_days']), 2) + late = self.archive / 'late.jsonl' + self.write(late, [self.meta('late'), {'type': 'turn_context', 'payload': {'model': 'gpt-test'}}, + self.event(500, 0, 100, -86400)]) + self.assertAlmostEqual(self.collect()['_spending']['amount'], 0.10829) + self.assertEqual(self.ledger()['days'][first_day]['prices']['gpt-test']['input'], 2) + + def test_spending_does_not_backdate_first_observed_prices(self): + self.spending_fixture() + self.records = [self.meta('one'), {'type': 'turn_context', 'payload': {'model': 'gpt-test'}}, + self.event(3000, 900, 1200, -86400)] + self.write(self.path, self.records) + estimate = self.collect()['_spending'] + self.assertIsNone(estimate['amount']) + self.assertTrue(estimate['partial']) + self.assertEqual(estimate['unpriced_tokens'], 3000) + self.assertEqual(estimate['priced_days'], []) + old = next(day for day in self.ledger()['days'].values() if day['unpriced_tokens']) + self.assertNotIn('prices', old) + + def test_spending_context_tiers_cache_write_and_reasoning_are_counted_once(self): + self.spending_fixture() + self.set_prices(tiers=[{'tier': {'type': 'context', 'size': 600}, + 'input': 4, 'output': 15, 'cache_read': 0.2, 'cache_write': 5}]) + event = self.event(1000, 200, 300, -60) + event['payload']['info']['total_token_usage'].update(cache_write_input_tokens=100, + reasoning_output_tokens=50) + self.records = self.records[:2] + [event] + self.write(self.path, self.records) + estimate = self.collect()['_spending'] + self.assertAlmostEqual(estimate['amount'], 0.00664) + self.assertEqual(estimate['priced_tokens'], 1000) + + def test_spending_missing_prices_and_nonstandard_tiers_are_unavailable(self): + self.spending_fixture() + self.set_prices(cache_read=None) + estimate = self.collect()['_spending'] + self.assertIsNone(estimate['amount']) + self.assertEqual(estimate['unpriced_tokens'], 3000) + self.records[1]['payload']['service_tier'] = 'fast' + self.write(self.path, self.records) + self.assertIsNone(self.collect()['_spending']['amount']) + + def test_spending_offline_rates_are_marked_stale_then_refresh_today_only(self): + self.spending_fixture() + self.collect() + self.now += 86400 + self.env['CODEX_USAGE_NOW'] = str(self.now) + self.env.pop('CODEX_USAGE_PRICES_JSON') + self.records.append(self.event(5000, 1500, 2000, -60)) + self.write(self.path, self.records) + offline = self.collect()['_spending'] + self.assertTrue(offline['stale_prices']) + self.assertAlmostEqual(offline['amount'], 0.02315) + self.set_prices(multiplier=10) + self.env['CODEX_USAGE_NOW'] = str(self.now+901) + refreshed = self.collect()['_spending'] + self.assertFalse(refreshed['stale_prices']) + self.assertAlmostEqual(refreshed['amount'], 0.10649) + + def test_spending_corrupt_ledger_is_not_overwritten(self): + self.spending_fixture() + month = datetime.fromtimestamp(self.now).strftime('%Y-%m') + path = self.home / 'spending' / f'spending-{month}.json' + path.parent.mkdir() + path.write_text('broken history') + self.assertIsNone(self.collect()['_spending']['amount']) + self.assertEqual(path.read_text(), 'broken history') + + def test_public_price_request_contains_no_login_credentials(self): + module = runpy.run_path(str(COLLECTOR)) + fixture = {'openai': {'models': {'gpt-test': {'cost': {'input': 2, 'output': 10}}}}} + opener = unittest.mock.MagicMock() + opener.open.return_value.__enter__.return_value.read.return_value = json.dumps(fixture).encode() + clean_env = dict(os.environ, CODEX_USAGE_DISABLE_PRICES='0', OPENAI_API_KEY='FAKE-SECRET') + clean_env.pop('CODEX_USAGE_PRICES_JSON', None) + with patch.dict(os.environ, clean_env, clear=True), patch.object(urllib_request := module['urllib'].request, + 'build_opener', return_value=opener) as build: + self.assertIn('gpt-test', module['fetch_prices']()) + request = opener.open.call_args.args[0] + self.assertEqual(request.full_url, 'https://models.dev/api.json') + self.assertIsNone(request.data) + self.assertNotIn('authorization', {key.lower() for key in request.headers}) + self.assertNotIn('FAKE-SECRET', str(request.headers)) + redirect = build.call_args.args[1] + self.assertIsNone(redirect.redirect_request(None, None, 302, '', {}, 'https://other.invalid')) + + def test_spending_survives_without_quota_rpc_or_snapshot(self): + self.spending_fixture() + self.env.pop('CODEX_USAGE_RATE_LIMITS_JSON') + cache = self.collect() + self.assertEqual(cache['_source'], 'local') + self.assertAlmostEqual(cache['_spending']['amount'], 0.01389) + + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/codex-usage.sh b/tests/codex-usage.sh new file mode 100755 index 000000000..bf12d5a54 --- /dev/null +++ b/tests/codex-usage.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# Fixture test for ryoku/rashin/usage/codex-usage: the AI usage card must read a +# real Codex rollout (session_meta, thread settings, token_count events) without +# the app-server, and keep the shape its cache promises: per-day buckets, +# per-session rows, per-period figures, and the plan's own 30-day window. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +collector="$here/../ryoku/rashin/usage/codex-usage" + +if ! command -v python3 >/dev/null 2>&1; then + echo "codex-usage: python3 not found, skipping" >&2 + exit 0 +fi + +fail=0 + +# check LABEL FIELD EXPECTED CACHE_JSON +check() { + local label="$1" field="$2" want="$3" cache="$4" got + if [[ ! -f "$cache" ]]; then + echo " FAIL: $label -> collector wrote no cache" >&2 + fail=1 + return + fi + got="$(OC_FIELD="$field" OC_CACHE="$cache" python3 -c \ +'import json, os +print(json.load(open(os.environ["OC_CACHE"])).get(os.environ["OC_FIELD"], ""))')" + if [[ "$got" == "$want" ]]; then + echo " ok: $label" + else + echo " FAIL: $label -> $field=$got, want $want" >&2 + fail=1 + fi +} + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +d="$work/rollout" +mkdir -p "$d/home/.cache" "$d/home/.codex/sessions/2026/11/06" +python3 - "$d/home/.codex/sessions/2026/11/06/rollout-fixture.jsonl" <<'PY' +import json, sys, time + +path = sys.argv[1] +now = int(time.time()) +iso = lambda offset: time.strftime( + "%Y-%m-%dT%H:%M:%SZ", time.gmtime(now + offset)) +session = "01a1b204-fixture" +cwd = "/home/test/project" +lines = [ + {"timestamp": iso(-300), "type": "session_meta", + "payload": {"id": session, "session_id": session, "cwd": cwd, + "timestamp": iso(-300)}}, + {"timestamp": iso(-299), "type": "event_msg", + "payload": {"type": "thread_settings_applied", + "thread_settings": {"model": "gpt-5.6-terra", + "model_provider_id": "openai"}}}, + {"timestamp": iso(-290), "type": "response_item", + "payload": {"type": "message", "role": "user", + "content": [{"type": "input_text", + "text": "Fix the failing snapshot test"}]}}, + {"timestamp": iso(-296), "type": "event_msg", + "payload": {"type": "task_started"}}, + {"timestamp": iso(-280), "type": "event_msg", + "payload": {"type": "token_count", + "info": {"total_token_usage": { + "input_tokens": 700, "cached_input_tokens": 200, + "output_tokens": 300, "total_tokens": 1000}}, + "rate_limits": { + "limit_id": "codex", "plan_type": "free", + "primary": {"used_percent": 0.0, + "window_minutes": 43200, + "resets_at": now + 2500000}}}}, + {"timestamp": iso(-60), "type": "event_msg", + "payload": {"type": "token_count", + "info": {"total_token_usage": { + "input_tokens": 1800, "cached_input_tokens": 900, + "output_tokens": 1200, "total_tokens": 3000}}, + "rate_limits": { + "limit_id": "codex", "plan_type": "free", + "primary": {"used_percent": 48.0, + "window_minutes": 43200, + "resets_at": now + 2500000}}}}, +] +lines.append({"timestamp": iso(-50), "type": "event_msg", + "payload": {"type": "task_complete"}}) +with open(path, "w") as fh: + for line in lines: + fh.write(json.dumps(line) + "\n") + +started = [ + {"timestamp": iso(-40), "type": "session_meta", + "payload": {"id": "01a1b204-started", "session_id": "01a1b204-started", + "cwd": "/home/test/project", "timestamp": iso(-40)}}, + {"timestamp": iso(-39), "type": "event_msg", + "payload": {"type": "thread_settings_applied", + "thread_settings": {"model": "gpt-5.6-terra", + "model_provider_id": "openai"}}}, + {"timestamp": iso(-30), "type": "response_item", + "payload": {"type": "message", "role": "user", + "content": [{"type": "input_text", + "text": "Start the long build"}]}}, + {"timestamp": iso(-20), "type": "event_msg", + "payload": {"type": "task_started"}}, +] +with open(str(path).replace("fixture.jsonl", "started.jsonl"), "w") as fh: + for line in started: + fh.write(json.dumps(line) + "\n") +PY + +cache="$d/home/.cache/codex-usage.json" +HOME="$d/home" CODEX_HOME="$d/home/.codex" \ + CODEX_USAGE_DISABLE_RPC=1 \ + CODEX_USAGE_DISABLE_PRICES=1 \ + CODEX_USAGE_STATE_DIR="$d/home/.local/state/ai-usage" \ + CODEX_USAGE_CACHE_FILE="$cache" \ + CODEX_USAGE_ACTIVITY_FILE="$d/home/.cache/codex-usage-activity.json" \ + "$collector" + +check "rollout is the source" _source rollout "$cache" +check "profile from the rollout" _plan free "$cache" + +read -r minutes utilization reset <<<"$(OC_CACHE="$cache" python3 -c ' +import json, os +window = (json.load(open(os.environ["OC_CACHE"])).get("_windows") or [{}])[0] +print(window.get("minutes", 0), window.get("utilization", 0), window.get("reset", 0))')" +if [[ "$minutes" == "43200" && "$utilization" == "0.48" && "$reset" -gt 0 ]]; then + echo " ok: 30-day window with its reset" +else + echo " FAIL: window -> minutes=$minutes utilization=$utilization reset=$reset" >&2 + fail=1 +fi + +read -r day_tokens day_cache day_count <<<"$(OC_CACHE="$cache" python3 -c ' +import json, os +days = json.load(open(os.environ["OC_CACHE"])).get("_days") or [] +print(sum(row["tokens"] for row in days), + sum(row["cacheRead"] for row in days), len(days))')" +if [[ "$day_tokens" == "2100" && "$day_cache" == "900" && "$day_count" -ge 1 ]]; then + echo " ok: day buckets keep the token deltas" +else + echo " FAIL: days -> tokens=$day_tokens cacheRead=$day_cache count=$day_count" >&2 + fail=1 +fi + +map="$(OC_CACHE="$cache" python3 -c ' +import json, os +rows = json.load(open(os.environ["OC_CACHE"])).get("_sessions") or [] +for row in rows: + print(row.get("title", "").replace(" ", "_") + "=" + str(row.get("running")))')" +if grep -q "Fix_the_failing_snapshot_test=False" <<<"$map" \ + && grep -q "Start_the_long_build=True" <<<"$map"; then + echo " ok: sessions carry tokens and turn lifecycle" +else + echo " FAIL: sessions -> $map" >&2 + fail=1 +fi + +read -r period_tokens <<<"$(OC_CACHE="$cache" python3 -c ' +import json, os +period = (json.load(open(os.environ["OC_CACHE"])).get("_periods") or {}).get("all") or {} +print(period.get("tokens", 0))')" +if [[ "$period_tokens" == "2100" ]]; then + echo " ok: period figures" +else + echo " FAIL: period -> tokens=$period_tokens" >&2 + fail=1 +fi + +if (( fail )); then + echo "codex-usage: FAILED" >&2 + exit 1 +fi +echo "codex-usage: rollout fixture read correctly" diff --git a/tests/custom-widget-install.py b/tests/custom-widget-install.py new file mode 100644 index 000000000..00039d938 --- /dev/null +++ b/tests/custom-widget-install.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Check shared widget installation and the unchanged shell cache readers.""" +import importlib.util +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +LOADER = ROOT / 'ryoku/shell/quickshell/inir/services/CustomWidgets.qml' + + +def functions(source, names): + result = [] + for name in names: + start = source.index(' function ' + name + '(') + end = source.index('\n }', start) + len('\n }') + body = re.sub(r': (?:string|var|bool)(?=[,)])', '', source[start:end]) + result.append(re.sub(r'\): (?:void|bool|var) \{', ') {', body)) + return '\n'.join(result) + + +def load_test(name): + spec = importlib.util.spec_from_file_location(name, ROOT / 'tests' / (name + '.py')) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class SharedWidgetTest(unittest.TestCase): + def run_node(self, script, *args): + result = subprocess.run(['node', '-e', script, *map(str, args)], + capture_output=True, text=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result.stdout + + def module_catalog_script(self): + loader = LOADER.read_text() + options = (ROOT / 'ryoku/shell/quickshell/inir/modules/iris/settings/IrisOptions.qml').read_text() + host = (ROOT / 'ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml').read_text() + script = functions(loader, ('_parseShipped', 'isInstalled')) + script += '\n' + functions(options, ('choicesOf',)) + script += '\nconst assert=require("node:assert/strict");' + script += '\nconst root={ready:true,widgets:[],shippedWidgets:[],widgetId:"ai-usage"};' + script += '\nconst CustomWidgets=root, IrisStyle={ryokuFrontend:false};' + if 'readonly property var irisModules:' in loader: + binding = load_test('ai-usage-widget').binding(loader, 'irisModules') + script += '\nObject.defineProperty(root,"irisModules",{get:()=>{' + binding + '\n}});' + row = next(line.strip().rstrip(',') for line in options.splitlines() + if 'path: "iris.bar.rightModules"' in line) + script += '\nconst spec=' + row + ';' + expression = re.search(r'readonly property var entry: ([\s\S]+?)\n readonly property string sourcePath:', host).group(1) + script += '\nconst selected=()=>(' + expression + ');' + scan = subprocess.run(['bash', str(ROOT / 'ryoku/shell/quickshell/inir/scripts/scan-widgets.sh'), + str(ROOT / 'ryoku/shell/quickshell/inir/defaults/widgets')], + capture_output=True, text=True, check=True) + script += '\n_parseShipped(' + json.dumps(scan.stdout) + ');' + return script + + def test_bundled_module_is_offered_and_loadable_on_a_fresh_profile(self): + script = self.module_catalog_script() + ''' +assert.deepEqual(root.widgets,[]); +assert.equal(isInstalled('ai-usage'),false); +assert.equal(spec.showIf(),true); +assert.deepEqual(spec.fallback,[]); +assert.deepEqual(choicesOf(spec).map(choice=>choice.value),['custom:ai-usage']); +const entry=selected(); +assert.equal(entry.id,'ai-usage'); +assert(entry.irisQmlPath.includes('/defaults/widgets/ai-usage/IrisCompact.qml')); +assert(require('node:fs').existsSync(new URL(entry.irisQmlPath))); +''' + self.run_node(script) + + def test_installed_modules_override_bundled_defaults_without_duplicates(self): + script = self.module_catalog_script() + ''' +const custom={id:'ai-usage',name:'My AI usage',irisQmlPath:'file:///custom/AI.qml'}; +const other={id:'other',name:'Other',irisQmlPath:'file:///custom/Other.qml'}; +root.widgets=[custom,other,{id:'desktop-only',irisQmlPath:''}]; +assert.deepEqual(choicesOf(spec).map(choice=>choice.value),['custom:ai-usage','custom:other']); +assert.equal(selected(),custom); +assert.equal(isInstalled('ai-usage'),true); +root.widgets=[{id:'ai-usage',irisQmlPath:''}]; +assert.deepEqual(choicesOf(spec),[]); +assert.equal(selected(),null); +root.widgets=[]; +assert.equal(selected().id,'ai-usage'); +assert.equal(isInstalled('ai-usage'),false); +''' + self.run_node(script) + + @unittest.skipUnless(shutil.which('qs'), 'Quickshell required for typed-list runtime check') + def test_bundled_catalog_reacts_to_installed_copies_in_qml(self): + source = LOADER.read_text() + binding = load_test('ai-usage-widget').binding(source, 'irisModules') + manifest = json.loads((ROOT / 'ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json').read_text()) + entries = json.dumps([{'id': 'ai-usage', 'dir': '/bundled/ai-usage', 'manifest': manifest}]) + qml = '''import QtQuick +import Quickshell +ShellRoot { + id: root + property list widgets: [] + property list shippedWidgets: [] + readonly property var irisModules: {''' + binding + ''' + } +''' + functions(source, ('_parseShipped',)) + ''' + Component.onCompleted: _parseShipped(''' + json.dumps(entries) + ''') + Timer { + interval: 30; running: true; repeat: true + property int phase: 0 + onTriggered: { + const expected = phase === 1 ? "file:///custom/AI.qml" : "file:///bundled/ai-usage/IrisCompact.qml"; + if (root.irisModules.length !== 1 || root.irisModules[0].irisQmlPath !== expected) { + console.log("BUNDLED-MODULE-RUNTIME-FAIL", phase, JSON.stringify(root.irisModules)); + Qt.quit(); return; + } + if (phase === 0) root.widgets = [{id:"ai-usage",irisQmlPath:"file:///custom/AI.qml"}]; + else if (phase === 1) root.widgets = []; + else { console.log("BUNDLED-MODULE-RUNTIME-PASS"); Qt.quit(); } + phase++; + } + } +}''' + with tempfile.TemporaryDirectory(prefix='bundled-module-qml-') as directory: + work = Path(directory) + runtime = work / 'runtime' + runtime.mkdir(mode=0o700) + (work / 'shell.qml').write_text(qml) + env = dict(os.environ, HOME=str(work / 'home'), XDG_CONFIG_HOME=str(work / 'config'), + XDG_CACHE_HOME=str(work / 'cache'), XDG_STATE_HOME=str(work / 'state'), + XDG_RUNTIME_DIR=str(runtime), QT_QPA_PLATFORM='offscreen', QS_DISABLE_CRASH_HANDLER='1') + result = subprocess.run(['qs', '-p', str(work / 'shell.qml')], env=env, + capture_output=True, text=True, timeout=10) + output = result.stdout + result.stderr + self.assertEqual(result.returncode, 0, output) + self.assertIn('BUNDLED-MODULE-RUNTIME-PASS', output) + self.assertNotIn('BUNDLED-MODULE-RUNTIME-FAIL', output) + + def test_original_example_api_and_install_guard(self): + script = functions(LOADER.read_text(), ('installShipped', 'installExample')) + script += ''' +const assert=require('node:assert/strict'); +const root={installShipped}; +const _installShippedProcess={widgetId:'unchanged',running:false}; +installExample(); +assert.equal(_installShippedProcess.widgetId,'example-widget'); +assert.equal(_installShippedProcess.running,true); +installShipped('ai-usage'); +assert.equal(_installShippedProcess.widgetId,'example-widget'); +for(const id of ['', '../other', 'x/y', '$(touch injected)', 'x";exit 1', null]) { + _installShippedProcess.running=false; + installShipped(id); + assert.equal(_installShippedProcess.running,false); + assert.equal(_installShippedProcess.widgetId,'example-widget'); +} +installShipped('ai-usage'); +assert.equal(_installShippedProcess.widgetId,'ai-usage'); +assert.equal(_installShippedProcess.running,true); +''' + self.run_node(script) + + def install_command(self, source, target): + text = LOADER.read_text() + start = text.index(' command: ', text.index('id: _installShippedProcess')) + end = text.index('\n stdout:', start) + expression = text[start:end].strip().removeprefix('command: ') + script = '''const root={shippedWidgetsDir:process.argv[1],widgetsDir:process.argv[2]}; +const _installShippedProcess={widgetId:'ai-usage'}; +process.stdout.write(JSON.stringify(''' + expression + '));' + return json.loads(self.run_node(script, source, target)) + + def test_install_handles_literal_paths_and_preserves_other_widgets(self): + with tempfile.TemporaryDirectory(prefix='widget-install-') as directory: + work = Path(directory) + source = work / 'defaults " $(touch injected)' + target = work / "user's widgets" + (source / 'ai-usage').mkdir(parents=True) + (source / 'ai-usage/widget.json').write_text('{}') + (source / 'ai-usage/.hidden').write_text('asset') + (target / 'other-widget').mkdir(parents=True) + (target / 'other-widget/keep').write_text('user content') + command = self.install_command(source, target) + first = subprocess.run(command, cwd=work, capture_output=True, text=True) + self.assertEqual(first.returncode, 0, first.stderr) + self.assertIn('done', first.stdout) + self.assertEqual((target / 'ai-usage/.hidden').read_text(), 'asset') + (target / 'ai-usage/widget.json').write_text('user edit') + second = subprocess.run(command, cwd=work, capture_output=True, text=True) + self.assertEqual(second.returncode, 0, second.stderr) + self.assertIn('exists', second.stdout) + self.assertEqual((target / 'ai-usage/widget.json').read_text(), 'user edit') + self.assertEqual((target / 'other-widget/keep').read_text(), 'user content') + self.assertFalse((work / 'injected').exists()) + self.assertEqual(list(target.glob('.install-*')), []) + + def test_failed_copy_does_not_publish_a_partial_widget(self): + with tempfile.TemporaryDirectory(prefix='widget-install-fail-') as directory: + work = Path(directory) + source, target, tools = work / 'defaults', work / 'widgets', work / 'bin' + (source / 'ai-usage').mkdir(parents=True) + tools.mkdir() + (tools / 'cp').write_text('#!/bin/sh\nexit 9\n') + (tools / 'cp').chmod(0o755) + result = subprocess.run(self.install_command(source, target), capture_output=True, + env=dict(os.environ, PATH=str(tools) + os.pathsep + os.environ['PATH'])) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((target / 'ai-usage').exists()) + self.assertEqual(list(target.glob('.install-*')), []) + + def test_existing_dangling_symlink_is_preserved(self): + with tempfile.TemporaryDirectory(prefix='widget-install-link-') as directory: + work = Path(directory) + source, target = work / 'defaults', work / 'widgets' + (source / 'ai-usage').mkdir(parents=True) + target.mkdir() + (target / 'ai-usage').symlink_to(work / 'missing') + result = subprocess.run(self.install_command(source, target), capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue((target / 'ai-usage').is_symlink()) + + def test_collector_output_remains_readable_by_other_shell_widgets(self): + fixture = load_test('codex-usage-regression').CodexUsageTest() + fixture.setUp() + self.addCleanup(fixture.doCleanups) + plus = fixture.collect() + fixture.set_limits('free', 43200) + free = fixture.collect() + contract = load_test('ai-usage-cache') + self.assertEqual(contract.main(), 0) + opencode = json.loads(contract.CACHE.read_text()) + source = (ROOT / 'ryoku/shell/quickshell/shell/services/AiUsage.qml').read_text() + names = ('emptyTool', 'percent', 'windowLabel', 'windowFromCache', 'windowsFromCache', + 'legacyWindows', 'codexBuckets', 'generalBucket', 'usageText', 'rateText', + 'parseLegacy', 'parseCodex') + script = functions(source, names) + '\nconst root={' + ','.join(names) + '};' + script += "\nconst I18n={tr:text=>text}; const assert=require('node:assert/strict');" + script += '\nconst data=' + json.dumps([plus, free, opencode]) + ';' + script += ''' +for(const [index,minutes] of [[0,[300,10080]],[1,[43200]]]) { + const result=root.parseCodex(data[index],true); + assert.equal(result.has,true); + assert.equal(result.fresh,true); + assert.equal(result.today,data[index]._today_tokens); + assert.deepEqual(result.windows.map(x=>x.minutes),minutes); + assert.equal(result.pct5h,index===0?25:0); +} +const result=root.parseLegacy('opencode',data[2],true); +assert.equal(result.has,true); +assert.equal(result.today,data[2]._today_tokens); +assert(result.models.length>0); +assert.equal(result.models[0].name,data[2]._models[0].name); +''' + self.run_node(script) + + +if __name__ == '__main__': + unittest.main(verbosity=2)