From f0c5a4f0fe7c5846fe080ce55db0c94508d92b4d Mon Sep 17 00:00:00 2001 From: methos28 <25006979+methos28@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:43:51 +0530 Subject: [PATCH 1/6] [global] ai-usage: add the AI usage widget Ships the widget, both collectors, the shipped-widget loader seam, tests and CI for the Shima AI usage module. Only OpenCode and Codex appear in the agent menus; a third agent stays out until its collector publishes the same history. Note: New: AI usage module on the Shima Island --- .github/workflows/ai-usage-collector.yml | 26 + ryoku/rashin/usage/codex-usage | 438 +++++++++++- ryoku/rashin/usage/opencode-usage | 666 +++++++++++++++--- .../defaults/widgets/ai-usage/AiUsageTile.qml | 91 +++ .../defaults/widgets/ai-usage/IrisCompact.qml | 608 ++++++++++++++++ .../defaults/widgets/ai-usage/UsageModel.qml | 412 +++++++++++ .../ai-usage/components/AgentAvailability.qml | 111 +++ .../components/AiUsageActivityCard.qml | 309 ++++++++ .../ai-usage/components/AiUsageEmptyCard.qml | 60 ++ .../ai-usage/components/AiUsageFoldHeader.qml | 60 ++ .../ai-usage/components/AiUsageGlassCard.qml | 35 + .../ai-usage/components/AiUsageGlassPane.qml | 67 ++ .../ai-usage/components/AiUsageLimitsCard.qml | 93 +++ .../ai-usage/components/AiUsageModelRow.qml | 77 ++ .../ai-usage/components/AiUsageModelsCard.qml | 70 ++ .../ai-usage/components/AiUsageNowCard.qml | 189 +++++ .../ai-usage/components/AiUsageSpendBody.qml | 78 ++ .../ai-usage/components/AiUsageSpendCard.qml | 40 ++ .../ai-usage/components/AiUsageTrendCard.qml | 189 +++++ .../ai-usage/components/AiUsageWindowRow.qml | 88 +++ .../defaults/widgets/ai-usage/widget.json | 89 +++ .../background/widgets/WidgetManagerPanel.qml | 33 +- .../inir/services/CustomWidgets.qml | 72 +- tests/ai-usage-cache.py | 160 +++++ tests/codex-usage-regression.py | 213 ++++++ tests/codex-usage.sh | 173 +++++ 26 files changed, 4309 insertions(+), 138 deletions(-) create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AgentAvailability.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageActivityCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageEmptyCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageFoldHeader.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageGlassPane.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageLimitsCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelRow.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelsCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageNowCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendBody.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageTrendCard.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageWindowRow.qml create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json create mode 100755 tests/ai-usage-cache.py create mode 100644 tests/codex-usage-regression.py create mode 100755 tests/codex-usage.sh diff --git a/.github/workflows/ai-usage-collector.yml b/.github/workflows/ai-usage-collector.yml index c21453067b..c792750999 100644 --- a/.github/workflows/ai-usage-collector.yml +++ b/.github/workflows/ai-usage-collector.yml @@ -13,7 +13,12 @@ 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' - '.github/workflows/ai-usage-collector.yml' push: branches: @@ -21,7 +26,12 @@ 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' - '.github/workflows/ai-usage-collector.yml' workflow_dispatch: @@ -39,3 +49,19 @@ 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 diff --git a/ryoku/rashin/usage/codex-usage b/ryoku/rashin/usage/codex-usage index 0c8fae9b13..ca0ec7c4fe 100755 --- a/ryoku/rashin/usage/codex-usage +++ b/ryoku/rashin/usage/codex-usage @@ -8,14 +8,14 @@ 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 hashlib, json, os, select, shutil, subprocess, sys, time 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 @@ -111,7 +111,7 @@ 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"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, env=ENV) except Exception as e: @@ -119,12 +119,23 @@ def fetch_rpc() -> dict | None: return None try: rpc_request(proc, 1, "initialize", - {"clientInfo": {"name": "codex-usage", "version": "1"}}, timeout=8) + {"clientInfo": {"name": "codex-usage", "version": "1"}}, timeout=15) proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n") proc.stdin.flush() - account = (rpc_request(proc, 2, "account/read", timeout=5) + except Exception as e: + sys.stderr.write(f"rpc error: {e}\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 {} - rate_limits_result = (rpc_request(proc, 3, "account/rateLimits/read", timeout=5) + except Exception: + pass + try: + rate_limits_result = (rpc_request(proc, 3, "account/rateLimits/read", timeout=10) .get("result") or {}) except Exception as e: sys.stderr.write(f"rpc error: {e}\n") @@ -478,6 +489,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 +526,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 +608,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 +650,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 +662,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 +705,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,6 +714,366 @@ 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 + lifecycle_ts = 0.0 + lifecycle_running = None + try: + with path.open(errors="replace") as handle: + for raw in handle: + 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) + continue + if kind == "turn_context": + model_id = str(payload.get("model") or model_id) + 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")) + 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 + last_total = total + last_cached = cached + last_output = output + 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({ + "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), + }) + 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" + now = now_epoch() + 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 main(): data = fetch_env() or fetch_rpc() or fetch_rollout() @@ -694,12 +1090,23 @@ def main(): 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 "") + messages, sessions = history_records() + 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 +1125,19 @@ def main(): "_rate_label": "Local activity (1h, incl. cached)", "_today_tokens": today_tok, "_recent_days": recent_days_list, + "_days": days, + "_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", diff --git a/ryoku/rashin/usage/opencode-usage b/ryoku/rashin/usage/opencode-usage index 7282aef9c3..0ae258195a 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 0000000000..539d07e902 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml @@ -0,0 +1,91 @@ +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: root.usage.has ? root.usage.percent7d + "% used" : "—" + 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: root.usage.has + Rectangle { + width: parent.width * Math.max(0, Math.min(100, root.usage.percent7d)) / 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 0000000000..4e79207c07 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml @@ -0,0 +1,608 @@ +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 string periodNoun: root.periodLabel.toLowerCase() + + 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 : [] + readonly property int periodTokens: root.activePeriod + ? Number(root.activePeriod.tokens) : usageModel.today + readonly property real periodCacheRate: root.activePeriod + ? Number(root.activePeriod.cacheRate) : 0 + // 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") + 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 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 + } + } + + // ── agent menu ─────────────────────────────────────────────────────── + MouseArea { + anchors.fill: parent + visible: agentMenu.open + z: 88 + onClicked: agentMenu.open = false + } + IrisSurface { + id: agentMenu + property bool open: false + visible: open + z: 92 + raised: true + outlined: true + width: Math.round(172 * IrisStyle.density) + height: agentColumn.implicitHeight + 2 * Math.round(6 * IrisStyle.density) + radius: IrisStyle.radiusTile + Rectangle { + anchors.fill: parent + radius: agentMenu.radius + color: "transparent" + border.width: 1 + border.color: IrisStyle.borderStrong + } + Column { + id: agentColumn + x: Math.round(6 * IrisStyle.density) + y: Math.round(6 * IrisStyle.density) + width: agentMenu.width - 2 * Math.round(6 * IrisStyle.density) + Repeater { + model: root.agentOptions + Item { + id: agentRow + required property var modelData + width: agentColumn.width + height: Math.round(30 * IrisStyle.density) + readonly property bool current: String(modelData?.value ?? "") === root.tool + + Rectangle { + anchors.fill: parent + radius: IrisStyle.radiusSmall + color: agentRow.current || agentArea.containsMouse + ? IrisStyle.fillHover : "transparent" + } + IrisText { + anchors.left: parent.left + anchors.leftMargin: Math.round(9 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + text: String(modelData?.label ?? "") + font.pixelSize: IrisStyle.typeLabel + font.weight: IrisStyle.weight(agentRow.current ? Font.DemiBold : Font.Medium) + color: agentRow.current ? IrisStyle.accent : IrisStyle.text + } + MaterialSymbol { + anchors.right: parent.right + anchors.rightMargin: Math.round(9 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + visible: agentRow.current + text: "check" + iconSize: Math.round(14 * IrisStyle.density) + color: IrisStyle.accent + } + MouseArea { + id: agentArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + CustomWidgets.setConfigValue("ai-usage", "tool", + String(agentRow.modelData?.value ?? "opencode")) + agentMenu.open = false + } + } + } + } + } + } + + // ── period menu ────────────────────────────────────────────────────── + MouseArea { + anchors.fill: parent + visible: periodMenu.open + z: 90 + onClicked: periodMenu.open = false + } + IrisSurface { + id: periodMenu + property bool open: false + visible: open + z: 91 + raised: true + outlined: true + width: Math.round(158 * IrisStyle.density) + height: menuColumn.implicitHeight + 2 * Math.round(6 * IrisStyle.density) + radius: IrisStyle.radiusTile + Rectangle { + anchors.fill: parent + radius: periodMenu.radius + color: "transparent" + border.width: 1 + border.color: IrisStyle.borderStrong + } + Column { + id: menuColumn + x: Math.round(6 * IrisStyle.density) + y: Math.round(6 * IrisStyle.density) + width: periodMenu.width - 2 * Math.round(6 * IrisStyle.density) + Repeater { + model: root.periodOptions + Item { + id: menuRow + required property var modelData + width: menuColumn.width + height: Math.round(28 * IrisStyle.density) + readonly property bool current: menuRow.modelData.value === root.periodKey + Rectangle { + anchors.fill: parent + radius: IrisStyle.radiusSmall + color: rowArea.containsMouse ? IrisStyle.fillHover : "transparent" + } + IrisText { + anchors.left: parent.left + anchors.leftMargin: Math.round(8 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + text: menuRow.modelData.label + font.pixelSize: IrisStyle.typeLabel + color: menuRow.current ? IrisStyle.accent : IrisStyle.text + } + MaterialSymbol { + anchors.right: parent.right + anchors.rightMargin: Math.round(8 * IrisStyle.density) + anchors.verticalCenter: parent.verticalCenter + visible: menuRow.current + text: "check" + iconSize: Math.round(14 * IrisStyle.density) + color: IrisStyle.accent + } + MouseArea { + id: rowArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + CustomWidgets.setConfigValue("ai-usage", "period", + String(menuRow.modelData.value)) + periodMenu.open = false + } + } + } + } + } + } +} 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 0000000000..6c5dbd68fa --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml @@ -0,0 +1,412 @@ +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 + 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 = [] + } + + 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 + 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 0000000000..a28481a14f --- /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 0000000000..f4363d3bf6 --- /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 0000000000..66c1d3a816 --- /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 0000000000..c7951e35c5 --- /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 0000000000..177adfe9a1 --- /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 0000000000..b28bbe0e59 --- /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 0000000000..2f5bd74269 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageLimitsCard.qml @@ -0,0 +1,93 @@ +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 + 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) + } + IrisText { + anchors.baseline: todayMetric.baseline + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "tokens today · incl. cached" + } + } + } + + 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/AiUsageModelRow.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageModelRow.qml new file mode 100644 index 0000000000..2177619eab --- /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 0000000000..7ef335ff78 --- /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 0000000000..4b42bd0ad1 --- /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 0000000000..a572748280 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendBody.qml @@ -0,0 +1,78 @@ +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 { + width: parent.width + height: spendTitle.implicitHeight + IrisText { + id: spendTitle + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + text: "Spending" + font.pixelSize: Math.round(12 * IrisStyle.density) + font.weight: IrisStyle.weight(Font.DemiBold) + } + IrisText { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + role: IrisText.Meta + color: IrisStyle.muted + font.pixelSize: Math.round(10 * IrisStyle.density) + text: "This month" + } + } + + Item { + width: parent.width + height: spendMetric.implicitHeight + Row { + id: spendRow + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + spacing: Math.round(6 * IrisStyle.density) + IrisText { + id: spendMetric + role: IrisText.Metric + text: body.page.money(body.page.monthly.current) + font.pixelSize: Math.round(24 * IrisStyle.density) + } + IrisText { + 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 0000000000..6d9cb48ea0 --- /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 0000000000..d963cae84b --- /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 0000000000..b3c69d1af3 --- /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 0000000000..3728de7362 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json @@ -0,0 +1,89 @@ +{ + "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", + "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 a81af23a0e..d8a9ca499f 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); CustomWidgets.reload() } + 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/services/CustomWidgets.qml b/ryoku/shell/quickshell/inir/services/CustomWidgets.qml index 36eeb8d185..f7b80b429b 100644 --- a/ryoku/shell/quickshell/inir/services/CustomWidgets.qml +++ b/ryoku/shell/quickshell/inir/services/CustomWidgets.qml @@ -13,14 +13,20 @@ 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: [] 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 +44,39 @@ 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; + list.push({ + id: entry.id, + name: manifest.name, + icon: manifest.icon || "widgets", + description: manifest.description || "" + }); + } + 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 +250,11 @@ Singleton { _removeProcess.running = true; } - // Install the built-in example widget - function installExample(): void { - _installExampleProcess.running = true; + // Copy a shipped widget from defaults into the user's widgets directory + function installShipped(widgetId: string): void { + if (!widgetId || widgetId.length === 0) return; + _installShippedProcess.widgetId = widgetId; + _installShippedProcess.running = true; } // Open widget directory in file manager @@ -411,20 +452,29 @@ 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" + src="${root.shippedWidgetsDir}/${_installShippedProcess.widgetId}" + dest="${root.widgetsDir}/${_installShippedProcess.widgetId}" [ -d "$src" ] || { echo "fail"; exit 1; } [ -e "$dest" ] && { echo "exists"; exit 0; } mkdir -p "$dest" - cp -r "$src"/* "$dest"/ + cp -r "$src"/. "$dest"/ echo "done" `] stdout: StdioCollector { diff --git a/tests/ai-usage-cache.py b/tests/ai-usage-cache.py new file mode 100755 index 0000000000..32dc3e799e --- /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/codex-usage-regression.py b/tests/codex-usage-regression.py new file mode 100644 index 0000000000..60d69c89c5 --- /dev/null +++ b/tests/codex-usage-regression.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""Regression cases for Codex rollout replay and account quota windows.""" +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import time +import unittest + +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)) + 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) + + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/tests/codex-usage.sh b/tests/codex-usage.sh new file mode 100755 index 0000000000..001194378b --- /dev/null +++ b/tests/codex-usage.sh @@ -0,0 +1,173 @@ +#!/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_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" From 31d36bef91ba6d93276b3e8df1a61434c77981c7 Mon Sep 17 00:00:00 2001 From: methos28 <25006979+methos28@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:39:28 +0530 Subject: [PATCH 2/6] [global] ai-usage: share one dropdown and trim rollout scan Replace the duplicated agent and period menus with one AiUsageMenu component and drop three unused period properties. Skip Codex rollout lines that cannot affect history, and hoist the clock out of the session loop. --- ryoku/rashin/usage/codex-usage | 11 +- .../defaults/widgets/ai-usage/IrisCompact.qml | 164 ++---------------- .../ai-usage/components/AiUsageMenu.qml | 92 ++++++++++ 3 files changed, 119 insertions(+), 148 deletions(-) create mode 100644 ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml diff --git a/ryoku/rashin/usage/codex-usage b/ryoku/rashin/usage/codex-usage index ca0ec7c4fe..bc7fecf2ee 100755 --- a/ryoku/rashin/usage/codex-usage +++ b/ryoku/rashin/usage/codex-usage @@ -771,6 +771,16 @@ def history_records() -> tuple[list[dict], dict]: 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: @@ -886,7 +896,6 @@ def history_records() -> tuple[list[dict], dict]: if not session["title"]: directory = str(session["directory"] or "").rstrip("/") session["title"] = directory.rsplit("/", 1)[-1] if directory else "Codex session" - now = now_epoch() state = session.pop("_running_state", None) session.pop("_lifecycle_ts", None) if state is None: diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml index 4e79207c07..cdc0af684b 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml @@ -89,7 +89,6 @@ Item { const option = root.periodOptions.find(entry => entry.value === root.periodKey) return option ? option.label : "Last 30 days" } - readonly property string periodNoun: root.periodLabel.toLowerCase() readonly property int pad: Math.round(10 * IrisStyle.density) readonly property int gap: Math.round(10 * IrisStyle.density) @@ -111,10 +110,6 @@ Item { // 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 : [] - readonly property int periodTokens: root.activePeriod - ? Number(root.activePeriod.tokens) : usageModel.today - readonly property real periodCacheRate: root.activePeriod - ? Number(root.activePeriod.cacheRate) : 0 // 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") @@ -453,156 +448,31 @@ Item { } } - // ── agent menu ─────────────────────────────────────────────────────── + // One dropdown implementation serves both pickers. MouseArea { anchors.fill: parent - visible: agentMenu.open + visible: agentMenu.open || periodMenu.open z: 88 - onClicked: agentMenu.open = false + onClicked: { + agentMenu.open = false + periodMenu.open = false + } } - IrisSurface { + AiUsageMenu { id: agentMenu - property bool open: false - visible: open z: 92 - raised: true - outlined: true - width: Math.round(172 * IrisStyle.density) - height: agentColumn.implicitHeight + 2 * Math.round(6 * IrisStyle.density) - radius: IrisStyle.radiusTile - Rectangle { - anchors.fill: parent - radius: agentMenu.radius - color: "transparent" - border.width: 1 - border.color: IrisStyle.borderStrong - } - Column { - id: agentColumn - x: Math.round(6 * IrisStyle.density) - y: Math.round(6 * IrisStyle.density) - width: agentMenu.width - 2 * Math.round(6 * IrisStyle.density) - Repeater { - model: root.agentOptions - Item { - id: agentRow - required property var modelData - width: agentColumn.width - height: Math.round(30 * IrisStyle.density) - readonly property bool current: String(modelData?.value ?? "") === root.tool - - Rectangle { - anchors.fill: parent - radius: IrisStyle.radiusSmall - color: agentRow.current || agentArea.containsMouse - ? IrisStyle.fillHover : "transparent" - } - IrisText { - anchors.left: parent.left - anchors.leftMargin: Math.round(9 * IrisStyle.density) - anchors.verticalCenter: parent.verticalCenter - text: String(modelData?.label ?? "") - font.pixelSize: IrisStyle.typeLabel - font.weight: IrisStyle.weight(agentRow.current ? Font.DemiBold : Font.Medium) - color: agentRow.current ? IrisStyle.accent : IrisStyle.text - } - MaterialSymbol { - anchors.right: parent.right - anchors.rightMargin: Math.round(9 * IrisStyle.density) - anchors.verticalCenter: parent.verticalCenter - visible: agentRow.current - text: "check" - iconSize: Math.round(14 * IrisStyle.density) - color: IrisStyle.accent - } - MouseArea { - id: agentArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - CustomWidgets.setConfigValue("ai-usage", "tool", - String(agentRow.modelData?.value ?? "opencode")) - agentMenu.open = false - } - } - } - } - } - } - - // ── period menu ────────────────────────────────────────────────────── - MouseArea { - anchors.fill: parent - visible: periodMenu.open - z: 90 - onClicked: periodMenu.open = false + options: root.agentOptions + currentValue: root.tool + onPicked: value => CustomWidgets.setConfigValue("ai-usage", "tool", value) } - IrisSurface { + AiUsageMenu { id: periodMenu - property bool open: false - visible: open z: 91 - raised: true - outlined: true - width: Math.round(158 * IrisStyle.density) - height: menuColumn.implicitHeight + 2 * Math.round(6 * IrisStyle.density) - radius: IrisStyle.radiusTile - Rectangle { - anchors.fill: parent - radius: periodMenu.radius - color: "transparent" - border.width: 1 - border.color: IrisStyle.borderStrong - } - Column { - id: menuColumn - x: Math.round(6 * IrisStyle.density) - y: Math.round(6 * IrisStyle.density) - width: periodMenu.width - 2 * Math.round(6 * IrisStyle.density) - Repeater { - model: root.periodOptions - Item { - id: menuRow - required property var modelData - width: menuColumn.width - height: Math.round(28 * IrisStyle.density) - readonly property bool current: menuRow.modelData.value === root.periodKey - Rectangle { - anchors.fill: parent - radius: IrisStyle.radiusSmall - color: rowArea.containsMouse ? IrisStyle.fillHover : "transparent" - } - IrisText { - anchors.left: parent.left - anchors.leftMargin: Math.round(8 * IrisStyle.density) - anchors.verticalCenter: parent.verticalCenter - text: menuRow.modelData.label - font.pixelSize: IrisStyle.typeLabel - color: menuRow.current ? IrisStyle.accent : IrisStyle.text - } - MaterialSymbol { - anchors.right: parent.right - anchors.rightMargin: Math.round(8 * IrisStyle.density) - anchors.verticalCenter: parent.verticalCenter - visible: menuRow.current - text: "check" - iconSize: Math.round(14 * IrisStyle.density) - color: IrisStyle.accent - } - MouseArea { - id: rowArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - CustomWidgets.setConfigValue("ai-usage", "period", - String(menuRow.modelData.value)) - periodMenu.open = false - } - } - } - } - } + 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/components/AiUsageMenu.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml new file mode 100644 index 0000000000..ba989a4976 --- /dev/null +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml @@ -0,0 +1,92 @@ +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.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 ?? "")) + } + } + } + } + } +} From f9b7a7dc1e00ed1ac17e0223805cb09024b6b2f7 Mon Sep 17 00:00:00 2001 From: methos28 <25006979+methos28@users.noreply.github.com> Date: Sat, 7 Nov 2026 18:39:35 +0530 Subject: [PATCH 3/6] [global] ai-usage: keep the today caption inside the card Stack the caption beside the metric and give the row an explicit width, so the text stays inside the card and the card keeps its original height. --- .../ai-usage/components/AiUsageLimitsCard.qml | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 index 2f5bd74269..81748d5200 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageLimitsCard.qml +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageLimitsCard.qml @@ -58,6 +58,7 @@ AiUsageGlassCard { height: todayRow.implicitHeight Row { id: todayRow + width: parent.width anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter spacing: Math.round(7 * IrisStyle.density) @@ -67,12 +68,28 @@ AiUsageGlassCard { text: usage.kmg(usage.today) font.pixelSize: Math.round(26 * IrisStyle.density) } - IrisText { - anchors.baseline: todayMetric.baseline - role: IrisText.Meta - color: IrisStyle.muted - font.pixelSize: Math.round(10 * IrisStyle.density) - text: "tokens today · incl. cached" + 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 + } } } } From f26ede8ad40a73852a6094bc57d784f9e1443731 Mon Sep 17 00:00:00 2001 From: methos28 <25006979+methos28@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:30:18 +0530 Subject: [PATCH 4/6] [global] ai-usage: add daily spending and harden widget handling Track Codex API-equivalent spending with private monthly ledgers and saved daily prices. Mark missing history and stale rates while preserving the compact layout and other agents' recorded costs. Bound stdio RPC reads, clean up failed child processes and publish private caches atomically. Prevent API-key and Bedrock accounts from displaying previous subscription quotas. Restore the shared example-install entry point, preserve existing widget content and stage installs before publishing them. Keep the packaged collector lookup and environment override intact. Validate with 43 collector, widget and shared compatibility tests, both collector fixtures, the cache contract, QML, delivery and WM isolation. Note: New: Codex shows estimated monthly spending from saved daily prices Note: Fixed: AI usage preserves compact layouts and existing widgets --- .github/workflows/ai-usage-collector.yml | 13 + ryoku/rashin/usage/codex-usage | 342 +++++++++++++++--- .../defaults/widgets/ai-usage/AiUsageTile.qml | 9 +- .../defaults/widgets/ai-usage/IrisCompact.qml | 21 ++ .../defaults/widgets/ai-usage/UsageModel.qml | 4 + .../ai-usage/components/AiUsageMenu.qml | 1 + .../ai-usage/components/AiUsageSpendBody.qml | 30 +- .../background/widgets/WidgetManagerPanel.qml | 2 +- .../inir/services/CustomWidgets.qml | 30 +- tests/ai-usage-widget.py | 254 +++++++++++++ tests/codex-usage-regression.py | 314 +++++++++++++++- tests/codex-usage.sh | 2 + tests/custom-widget-install.py | 158 ++++++++ 13 files changed, 1117 insertions(+), 63 deletions(-) create mode 100644 tests/ai-usage-widget.py create mode 100644 tests/custom-widget-install.py diff --git a/.github/workflows/ai-usage-collector.yml b/.github/workflows/ai-usage-collector.yml index c792750999..850afc23bf 100644 --- a/.github/workflows/ai-usage-collector.yml +++ b/.github/workflows/ai-usage-collector.yml @@ -19,6 +19,10 @@ on: - '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/background/widgets/WidgetManagerPanel.qml' - '.github/workflows/ai-usage-collector.yml' push: branches: @@ -32,6 +36,10 @@ on: - '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/background/widgets/WidgetManagerPanel.qml' - '.github/workflows/ai-usage-collector.yml' workflow_dispatch: @@ -65,3 +73,8 @@ jobs: - 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/ryoku/rashin/usage/codex-usage b/ryoku/rashin/usage/codex-usage index bc7fecf2ee..ee8d71695c 100755 --- a/ryoku/rashin/usage/codex-usage +++ b/ryoku/rashin/usage/codex-usage @@ -8,7 +8,8 @@ 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 hashlib, 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 @@ -18,6 +19,9 @@ ACTIVITY_FILE = Path(os.environ.get("CODEX_USAGE_ACTIVITY_FILE") or (Path.home() 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,43 +141,57 @@ def fetch_rpc() -> dict | None: return None try: proc = subprocess.Popen( - [codex, "-s", "read-only", "-a", "never", "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") - 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 as e: - sys.stderr.write(f"rpc error: {e}\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 - try: - rate_limits_result = (rpc_request(proc, 3, "account/rateLimits/read", timeout=10) - .get("result") or {}) - except Exception as e: - sys.stderr.write(f"rpc error: {e}\n") + sys.stderr.write("app-server spawn failed\n") return None + try: + 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") @@ -766,6 +810,8 @@ def history_records() -> tuple[list[dict], dict]: last_total = 0 last_cached = 0 last_output = 0 + last_write = 0 + service_tier = "standard" lifecycle_ts = 0.0 lifecycle_running = None try: @@ -801,9 +847,11 @@ def history_records() -> tuple[list[dict], dict]: 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 "") @@ -833,12 +881,15 @@ def history_records() -> tuple[list[dict], dict]: 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 @@ -848,6 +899,7 @@ def history_records() -> tuple[list[dict], dict]: 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, @@ -860,6 +912,9 @@ def history_records() -> tuple[list[dict], dict]: "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 @@ -1083,10 +1138,188 @@ def published_codes() -> dict: 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: @@ -1097,6 +1330,8 @@ 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() # Honour the selected snapshot as a whole. Filling its absent windows from @@ -1106,7 +1341,10 @@ def main(): max_util, quota_bucket, quota_window_label = quota_window(general) reached_type = str(general.get("rateLimitReachedType") or "") - messages, sessions = history_records() + 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) @@ -1135,6 +1373,7 @@ def main(): "_today_tokens": today_tok, "_recent_days": recent_days_list, "_days": days, + "_spending": spending, "_hours": hours, "_sessions": history_sessions(sessions), "_windows": windows, @@ -1162,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/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml index 539d07e902..6bf1974062 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/AiUsageTile.qml @@ -64,7 +64,10 @@ AbstractBackgroundWidget { } StyledText { - text: root.usage.has ? root.usage.percent7d + "% used" : "—" + 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 @@ -79,9 +82,9 @@ AbstractBackgroundWidget { height: Math.round(4 * root.scaleFactor) radius: height / 2 color: ColorUtils.applyAlpha(root.colText, 0.15) - visible: root.usage.has + visible: usage.has && usage.hasPlan && usage.windows.length > 0 Rectangle { - width: parent.width * Math.max(0, Math.min(100, root.usage.percent7d)) / 100 + 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 index cdc0af684b..0faeec07f6 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/IrisCompact.qml @@ -113,6 +113,15 @@ Item { // 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) @@ -120,6 +129,18 @@ Item { } 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 diff --git a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml index 6c5dbd68fa..74a3371840 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/UsageModel.qml @@ -36,6 +36,7 @@ Item { 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 @@ -170,6 +171,7 @@ Item { root.sessions = [] root.periods = ({}) root.windows = [] + root.spending = ({}) } function pct(value: string): int { @@ -233,6 +235,8 @@ Item { : "" 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)) 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 index ba989a4976..f4a05a3bed 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageMenu.qml @@ -7,6 +7,7 @@ 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 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 index a572748280..1575187085 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendBody.qml +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/components/AiUsageSpendBody.qml @@ -31,23 +31,40 @@ Item { 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: "Spending" + 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" + 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 + } } } @@ -56,16 +73,23 @@ Item { 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.money(body.page.monthly.current) + 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 diff --git a/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml b/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml index d8a9ca499f..1bb80bba46 100644 --- a/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml +++ b/ryoku/shell/quickshell/inir/modules/background/widgets/WidgetManagerPanel.qml @@ -721,7 +721,7 @@ Item { 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); CustomWidgets.reload() } + 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/services/CustomWidgets.qml b/ryoku/shell/quickshell/inir/services/CustomWidgets.qml index f7b80b429b..dd289b357a 100644 --- a/ryoku/shell/quickshell/inir/services/CustomWidgets.qml +++ b/ryoku/shell/quickshell/inir/services/CustomWidgets.qml @@ -252,11 +252,17 @@ Singleton { // Copy a shipped widget from defaults into the user's widgets directory function installShipped(widgetId: string): void { - if (!widgetId || widgetId.length === 0) return; + 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 { + root.installShipped("example-widget"); + } + // Open widget directory in file manager function openWidgetDir(widgetId: string): void { const dirPath = widgetId ? `${root.widgetsDir}/${widgetId}` : root.widgetsDir; @@ -469,14 +475,22 @@ IRIS_QML property string widgetId: "" running: false command: ["bash", "-c", ` - src="${root.shippedWidgetsDir}/${_installShippedProcess.widgetId}" - dest="${root.widgetsDir}/${_installShippedProcess.widgetId}" + 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-widget.py b/tests/ai-usage-widget.py new file mode 100644 index 0000000000..40c4ca5200 --- /dev/null +++ b/tests/ai-usage-widget.py @@ -0,0 +1,254 @@ +#!/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') + 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 index 60d69c89c5..c8a3f49885 100644 --- a/tests/codex-usage-regression.py +++ b/tests/codex-usage-regression.py @@ -1,13 +1,20 @@ #!/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')) @@ -29,7 +36,9 @@ def setUp(self): 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_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)] @@ -207,6 +216,309 @@ def test_actual_qml_window_parser_and_reset_countdown(self): ''' 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__': diff --git a/tests/codex-usage.sh b/tests/codex-usage.sh index 001194378b..bf12d5a547 100755 --- a/tests/codex-usage.sh +++ b/tests/codex-usage.sh @@ -112,6 +112,8 @@ 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" diff --git a/tests/custom-widget-install.py b/tests/custom-widget-install.py new file mode 100644 index 0000000000..385729691a --- /dev/null +++ b/tests/custom-widget-install.py @@ -0,0 +1,158 @@ +#!/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 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) \{', ') {', 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 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) From 8a3c43924b63aac431d90dffdb1f2bee71894675 Mon Sep 17 00:00:00 2001 From: methos28 <25006979+methos28@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:02:16 +0530 Subject: [PATCH 5/6] [docs] changelog: add the Shima AI usage dashboard Document the AI usage widget under Unreleased / Added, including OpenCode recorded costs and the meaning and coverage of Codex estimated spending. --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 099cd83527..b0387a6632 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.** Install a widget 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 From 9e4268ff2755c2b0a9878d0d03e1144e85591d1a Mon Sep 17 00:00:00 2001 From: methos28 <25006979+methos28@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:25:21 +0530 Subject: [PATCH 6/6] [global] ai-usage: list the bundled module in Island settings Offer packaged AI Usage under Your modules without a manual widget installation. Keep activation optional and prefer installed user copies. Use one module catalog for settings and the Island loader while preserving the desktop widget registry and manual install behavior. Cover fresh profiles, user-copy precedence, packaged load paths and reactive QML typed lists. All 46 focused tests and the delivery gates pass. Note: Fixed: AI usage appears in Island settings after an update --- .github/workflows/ai-usage-collector.yml | 4 + CHANGELOG.md | 10 +- .../defaults/widgets/ai-usage/widget.json | 1 + .../modules/iris/bar/IrisCustomModule.qml | 2 +- .../modules/iris/settings/IrisOptions.qml | 5 +- .../inir/services/CustomWidgets.qml | 16 ++- tests/ai-usage-widget.py | 2 + tests/custom-widget-install.py | 104 +++++++++++++++++- 8 files changed, 133 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ai-usage-collector.yml b/.github/workflows/ai-usage-collector.yml index 850afc23bf..0d18d39de9 100644 --- a/.github/workflows/ai-usage-collector.yml +++ b/.github/workflows/ai-usage-collector.yml @@ -22,6 +22,8 @@ on: - '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: @@ -39,6 +41,8 @@ on: - '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: diff --git a/CHANGELOG.md b/CHANGELOG.md index b0387a6632..94c49f1620 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,11 +106,11 @@ for finer detail. mutable channels current without changing frozen distro snapshots. ### Added -- **AI usage comes to Shima.** Install a widget 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. +- **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/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json index 3728de7362..ba839c7190 100644 --- a/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json +++ b/ryoku/shell/quickshell/inir/defaults/widgets/ai-usage/widget.json @@ -8,6 +8,7 @@ "main": "AiUsageTile.qml", "iris": { "main": "IrisCompact.qml", + "builtin": true, "slots": [ "island.desktop" ] diff --git a/ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml b/ryoku/shell/quickshell/inir/modules/iris/bar/IrisCustomModule.qml index d16c28b331..1ed1bbb7a4 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 01a6f92d67..c1ebd6e7ea 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 dd289b357a..87fdd8578c 100644 --- a/ryoku/shell/quickshell/inir/services/CustomWidgets.qml +++ b/ryoku/shell/quickshell/inir/services/CustomWidgets.qml @@ -15,6 +15,16 @@ Singleton { 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")) @@ -57,11 +67,15 @@ Singleton { 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 || "" + description: manifest.description || "", + irisBuiltin: iris.builtin === true, + irisQmlPath: irisFile.length > 0 ? `file://${entry.dir}/${irisFile}` : "" }); } root.shippedWidgets = list; diff --git a/tests/ai-usage-widget.py b/tests/ai-usage-widget.py index 40c4ca5200..467d237d26 100644 --- a/tests/ai-usage-widget.py +++ b/tests/ai-usage-widget.py @@ -213,6 +213,8 @@ def test_real_qml_components_compile_without_starting_services(self): 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]) diff --git a/tests/custom-widget-install.py b/tests/custom-widget-install.py index 385729691a..00039d9383 100644 --- a/tests/custom-widget-install.py +++ b/tests/custom-widget-install.py @@ -5,6 +5,7 @@ import os from pathlib import Path import re +import shutil import subprocess import tempfile import unittest @@ -19,7 +20,7 @@ def functions(source, 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) \{', ') {', body)) + result.append(re.sub(r'\): (?:void|bool|var) \{', ') {', body)) return '\n'.join(result) @@ -37,6 +38,107 @@ def run_node(self, script, *args): 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 += '''