# Limit Warning Icon + Notification — Design

Date: 2026-07-03
Slug: `limit-warning-icon`

## 1. Problem

The tray icon and menu give no proactive signal when the current default account is close to exhausting its Codex usage window. A user only discovers exhaustion by opening the dashboard/menu after the fact. We need: (a) the tray icon itself to flip to a warning state, and (b) a one-time OS notification, when the **current default account**'s remaining usage drops under a threshold on either its 5h or 7d window.

Thresholds (from user): 5h window warns under **15% remaining**; 7d window warns under **5% remaining**.

## 2. Scope

- Applies per-registry: Codex (`self.registry`) and Claude (`self.claude_registry`, when configured). Both registries already flow through the same `AccountSnapshot`/`_apply_snapshot` pipeline (`indicator.py:856-874`), so the feature is written against that generic pipeline — it is live for Codex today (Codex has a working `AccountHealthClient`) and activates for Claude automatically once `docs/superpowers/specs/2026-07-03-claude-account-parity-design.md` lands its `ClaudeHealthClient`, with zero changes to this feature. No Claude-specific code is written here.
- **Known limitation, confirmed against this machine's actual state (`~/.systray-ai/default_slug` = `rafa`, `~/.systray-ai/claude_default_slug` = `claude` — both a Codex and a Claude default are configured):** until `claude-account-parity` merges, Claude snapshots never leave `HealthStatus.UNKNOWN`, so `breached_windows()` always returns empty for the Claude default and this feature is a **silent no-op for the Claude default account**. It is live only for the Codex default in the meantime. This must be stated plainly to the user at handoff, not buried — it directly affects whether the feature does what was asked today.
- Only the **current default account per registry** is evaluated — not all accounts. "Current default" = `registry.default_slug()` at the moment a fresh snapshot for that account lands.
- Only snapshots with `status == HealthStatus.OK` and a non-`None` `used_pct` are evaluated for a given window; `BROKEN`/`UNKNOWN` snapshots or a `None` window value never contribute a breach (nothing to warn about).

## 3. Behavior

### 3.1 Icon state
- Two logical icon states: **normal** and **warning**.
- Warning state is active whenever **any** currently-default account (across configured registries) has **any** window (5h or 7d) breached (remaining % under its threshold), using the *latest known snapshot* for that account.
- State is recomputed after every snapshot merge (`_apply_snapshot`), and flips back to normal automatically once no default account is breached (e.g. after the window resets and a fresh snapshot confirms recovery) — no manual dismissal.
- Icon calls are idempotent: only call the GTK icon-swap API when the computed state actually differs from the last-applied state (avoid redundant `set_icon_full` calls every tick).

### 3.2 Notification
- Fires once per **crossing into breach**, per (account, window) pair — not on every tick while still breached, not again until the account recovers (window no longer breached) and then re-breaches.
- Does not fire on recovery (crossing back over the threshold).
- Message identifies the **tool**, the account, and which window breached and the remaining percent, e.g. `"Codex account 'rafa' is under 15% remaining on its 5h limit (12% left)"` — the tray icon is shared across both registries, so the notification text is the only place that disambiguates which tool/account breached.
- Delivered via the existing `Indicator._notify(message: str)` (`indicator.py:358-366`) — no new notification mechanism.
- `_limit_breach_state` is in-memory only (not persisted to `health_cache.json`). An app restart while already breached re-notifies once on the next snapshot — i.e. "once per crossing, per app session," not per absolute crossing. Intentional: the health cache exists to avoid re-fetching from the network, not to preserve UI-notification dedup state, and persisting a second piece of state for this would be YAGNI.

## 4. Architecture

### 4.1 New module: `limit_warning.py`

Pure, GTK-free, independently testable. No dependency on `indicator.py` or GTK/GLib.

```python
FIVE_HOUR_WARNING_THRESHOLD_PCT: int = 15
SEVEN_DAY_WARNING_THRESHOLD_PCT: int = 5

WINDOW_5H: str = "5h"
WINDOW_7D: str = "7d"

def breached_windows(snapshot: AccountSnapshot) -> frozenset[str]:
    ...
```

- `breached_windows`: returns the subset of `{WINDOW_5H, WINDOW_7D}` where the snapshot is `HealthStatus.OK`, the relevant `*_used_pct` is not `None`, and `100 - clamp(used_pct, 0, 100) < threshold` (5h uses `primary_used_pct` vs `FIVE_HOUR_WARNING_THRESHOLD_PCT`; 7d uses `secondary_used_pct` vs `SEVEN_DAY_WARNING_THRESHOLD_PCT`). Self-contained remaining-percent formula (mirrors `tray_model._remaining_percent`, `tray_model.py:104-107`) — duplicated as a two-line pure calculation rather than importing a private (`_`-prefixed) symbol cross-module.
- Empty frozenset ⇒ not breached on any window. Pure function, deterministic, trivially unit-testable against constructed `AccountSnapshot` values.

### 4.2 `Indicator` changes (`indicator.py`)

New instance state (init alongside `self.snapshots`, `indicator.py:146`):
- `self._limit_breach_state: dict[str, frozenset[str]]` — last-known breached-window set per `self._account_key(account)`, used only for crossing-detection (notify dedup). Persists across ticks; stale entries for accounts no longer default are harmless (never read once the account isn't default).
- `self._icon_warning_active: bool = False` — last-applied icon state, for idempotent icon swaps.

`_apply_snapshot(self, account: Account, snapshot: AccountSnapshot, generation: int | None)` (`indicator.py:856-874`) has `account` in scope but **not** `registry` — it only checks `account.tool == self._registry_tool(self.registry)` to decide dashboard updates (`indicator.py:864-865`). It also only writes `self.snapshots[key]` when `self._merge_snapshot(key, snapshot)` returns non-`None` (`indicator.py:860-862`) — the merged value, not the raw `snapshot` argument, is the one that reflects current state. `_check_limit_warning` must derive its own registry and use the merged value:

```python
def _check_limit_warning(self, account: Account, merged_snapshot: AccountSnapshot) -> None:
    ...
```
- **Placement, exact:** `_apply_snapshot` has an early return on the scheduled-refresh path — `if generation is not None: self._finish_refresh(generation); return` (`indicator.py:869-871`) — which the 60s timer path always takes, skipping the trailing `self._write_health_cache()` call. Calling `_check_limit_warning` "at the end" of the function would place it after that return and it would **never fire on the timer path**. It must be called **inside the `if merged_snapshot is not None:` block, immediately after `self.snapshots[key] = merged_snapshot`** (`indicator.py:860-862`) — before the `generation` branch. It needs neither `generation` nor the cache write, and this placement satisfies the "only when merged_snapshot is not None" guard structurally rather than as a separate check.
- Resolves registry internally: `registry = self.registry if account.tool == self._registry_tool(self.registry) else self.claude_registry` (mirrors the existing `account.tool == self._registry_tool(self.registry)` check at `indicator.py:864`).
- No-ops unless `registry is not None and registry.default_slug() == account.slug` (only the current default is evaluated).
- Computes `new_breached = limit_warning.breached_windows(merged_snapshot)`, diffs against `self._limit_breach_state.get(key, frozenset())` (`key = self._account_key(account)`, same key `_apply_snapshot` already computed) to find `newly_breached = new_breached - old_breached`, notifies once per entry in `newly_breached` via `self._notify(...)`, then stores `new_breached` back into `self._limit_breach_state[key]`.
`_check_limit_warning` also calls `self._update_warning_icon()` unconditionally at the end (idempotent — no-ops when state is unchanged, per the `_icon_warning_active` guard below). This keeps the icon flip synchronized with the notification on the primary path: `_refresh_all_timer` only *schedules* async fetches (`_refresh_all(force=True)` → background fetch → `_apply_snapshot` completes later), so an icon update driven purely by the timer tick would read stale snapshots and lag the notification by up to 60s — the user would get pinged and see a normal icon. Calling it from both `_check_limit_warning` (instant, matches the notification) and the heartbeat (§ below, covers default changes that don't go through a snapshot update) closes both gaps at once.

New method, recomputes global icon state from scratch (does not trust `_limit_breach_state`, which is dedup-only):

```python
def _update_warning_icon(self) -> None:
    ...
```
- Guarded `if self.indicator is None: return` (mirrors `_update_title`'s existing guard style).
- For each configured registry (`self.registry`, and `self.claude_registry` if not `None`), resolves the current default account (reuse existing `self._default_account(registry)`, `indicator.py:338-346` — currently unused, this feature is its first call site), looks up `self.snapshots.get(self._account_key(account))` (same key helper `_apply_snapshot` uses — do not use `account.tray_key` directly even though the two are equal today, to keep one key derivation path across the feature), and computes `limit_warning.breached_windows(snapshot)` if a snapshot exists.
- `warning = any` non-empty breach set across those lookups.
- If `warning != self._icon_warning_active`: apply the icon swap (§4.3) and set `self._icon_warning_active = warning`.
- **Call site: `_refresh_all_timer`** (`indicator.py:827-829`), called unconditionally on every 60s tick, not gated on any snapshot actually changing. This is deliberate, not just "call it from `on_activate` too": `_check_limit_warning` only runs when `_merge_snapshot` returns non-`None` for the account whose poll just completed. If the default switches to an account via a path other than the in-app `on_activate` menu click — e.g. external `cdx`/`cld` wrapper rewriting `default_slug`, or account removal — no snapshot changes and the icon would show the *previous* default's state indefinitely, not just for one tick. Driving the icon off the heartbeat instead of off `_check_limit_warning` covers every default-change path uniformly, at the cost of one cheap idempotent recompute per minute (no-op when state is unchanged, per the guard above).
- `on_activate` (`indicator.py:319-325`) additionally calls `self._update_warning_icon()` right after `target_registry.set_default(account)`, purely for instant visual feedback on the in-app switch path — the timer call is what guarantees correctness on every other path, this is a UX nicety on top.

### 4.3 Icon swap

- New icon asset: `icons/codex-account-switcher-warning.svg` (visually: same mark, warning color treatment — asset content out of scope for this spec, produced during implementation).
- New constant `APPINDICATOR_ICON_WARNING = os.environ.get("CODEX_TRAY_ICON_WARNING_NAME", "codex-account-switcher-warning")`, mirroring the existing `APPINDICATOR_ICON` pattern (`indicator.py:33-36`).
- `_update_warning_icon` calls `self.indicator.set_icon_full(APPINDICATOR_ICON_WARNING, "Usage limit warning")` when entering warning state, and `self.indicator.set_icon_full(APPINDICATOR_ICON, APP_DISPLAY_NAME)` when returning to normal — guarded by `hasattr(self.indicator, "set_icon_full")` matching the existing defensive `hasattr` style used for `connect` (`indicator.py:161-165`).
- `install.py`: extend the existing single-icon copy (`ICON_FILENAME`/`ICON_TEMPLATE`, `install.py:14-15,56-57`) to also copy the new warning icon file to the same `dest_icons` directory, following the identical `_ensure_copied_file` call pattern — same treatment in `install()` and `uninstall()`.
- **Upgrade path caveat:** `set_icon_full` resolves the warning icon by *name* through the hicolor theme directory. On an already-installed system, that name won't resolve until the user re-runs `install.py` to copy the new asset — the `hasattr` guard only checks the method exists, not that the icon resolves. This spec does not add a fallback (e.g. falling back to the title-text warning if the icon fails to resolve) — out of scope; call out in release notes / packaging docs that upgrading requires re-running `install.py`.

### 4.4 Test doubles

- `tests/test_indicator.py`'s `_FakeAppIndicatorInstance` (currently implements only `set_status`, `set_menu`, `set_title`, `connect`, `emit`, lines ~191-217) needs a `set_icon_full(icon_name, description)` method that records calls, so tests can assert icon-swap behavior without touching real GTK.

## 5. Data flow

```
Notification path (event-driven, per changed snapshot):
health poll (60s timer or manual refresh)
  → AccountHealthClient.fetch() → AccountSnapshot
  → Indicator._apply_snapshot()   [existing, indicator.py:856-874]
      → if merged_snapshot is not None:
          → self.snapshots[key] = merged_snapshot   [existing]
          → self._check_limit_warning(account, merged_snapshot)   [NEW — inserted here, before the generation/return branch]
              → limit_warning.breached_windows(merged_snapshot)
              → diff vs self._limit_breach_state → notify newly-breached windows
      → self._update_account_menu_item(account)   [existing, unchanged]
      → if generation is not None: self._finish_refresh(generation); return   [existing, unchanged]
      → self._write_health_cache()        [existing, unchanged]

Icon path (state-driven, heartbeat + instant feedback on manual switch):
Indicator._refresh_all_timer()   [existing, indicator.py:827-829, fires every 60s]
  → self._update_warning_icon()   [NEW call site]
      → recompute across all default accounts' latest snapshots
      → set_icon_full(...) iff state changed vs self._icon_warning_active

on_activate (existing, indicator.py:319-325, user picks a new default from the menu)
  → target_registry.set_default(account)   [existing]
  → self._update_warning_icon()   [NEW call site — instant feedback only; the timer call above is what guarantees eventual correctness]
```

## 6. Error handling

- Missing/`None` `used_pct`, or `status != OK` → treated as "not breached" for that window (never raises, never notifies on stale/unknown data).
- `set_icon_full` absence (older AppIndicator binding) → guarded by `hasattr`, silently skipped, matching existing defensive style.
- `notify-send` binary absent → already handled by existing `_notify` (`indicator.py:358-366`), unchanged.

## 7. Testing

- `limit_warning.py`: pure unit tests — table of `(primary_used_pct, secondary_used_pct, status)` → expected `breached_windows()` result, covering: both under threshold, only 5h, only 7d, neither, exactly-at-threshold boundary (not breached, since condition is strict `<`), `BROKEN`/`UNKNOWN` status, `None` values.
- `tests/test_indicator.py`: extend with cases mirroring existing `_apply_snapshot`/notification tests (pattern at `tests/test_indicator.py:1120-1129`) —
  - default account crosses into 5h breach → one `_notify` call, icon swaps to warning.
  - already-breached account gets another low snapshot → no additional `_notify` call (dedup holds).
  - breached account recovers (snapshot back above threshold) → no `_notify` call, icon swaps back to normal.
  - non-default account breaching → no notification, no icon change.
  - Claude registry absent (`self.claude_registry is None`) → `_update_warning_icon` does not error.
  - switching default account (`on_activate`, `indicator.py:319-325`) recomputes and applies the icon immediately, not on the next poll tick.

## 8. Files changed

- `limit_warning.py` — new file (§4.1).
- `indicator.py` — new instance state, `_check_limit_warning`, `_update_warning_icon`, `APPINDICATOR_ICON_WARNING` constant, call sites in `_apply_snapshot` (notify), `_refresh_all_timer` (icon heartbeat), and `_make_activate_handler`'s `on_activate` (icon instant feedback) (§4.2, §4.3).
- `icons/codex-account-switcher-warning.svg` — new asset.
- `install.py` — copy the new icon alongside the existing one, in both `install()` and `uninstall()` (§4.3).
- `tests/test_indicator.py` — `_FakeAppIndicatorInstance.set_icon_full` + new test cases (§4.4, §7).
- `tests/test_limit_warning.py` — new file, pure unit tests (§7).

## Architecture Decisions

- **`limit_warning.py` as a separate module, not inlined into `tray_model.py` or `indicator.py`**: passes the deletion test — deleting it would scatter threshold/breach logic into `indicator.py` (GTK-coupled, harder to unit test) or `tray_model.py` (menu-formatting concerns, different responsibility). Single-adapter today (only `indicator.py` calls it), but justified because it isolates a pure, independently-testable calculation from I/O and GTK — a genuine seam, not decorative.
- **Rejected: separate GLib timer for threshold checks.** Considered piggybacking a second `timeout_add_seconds` call dedicated to threshold checking. Rejected — `_apply_snapshot` already fires exactly when new data arrives (both on the 60s poll and manual refresh), so a second timer would only add redundant polling and drift risk with no new information. Hooking `_check_limit_warning` directly into `_apply_snapshot` is simpler and event-accurate.
- **Rejected: badge/overlay icon instead of full icon swap.** No existing icon-compositing infrastructure exists in this repo (single static SVG, no overlay rendering). Building one would be new infrastructure disproportionate to this feature; `set_icon_full` swap is the standard AppIndicator pattern and requires only one new asset.
