# Limit Warning Icon + Notification Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Flip the tray icon to a warning state and fire a one-time OS notification when the current default account (Codex and/or Claude) drops under 15% remaining on its 5h window or 5% remaining on its 7d window.

**Architecture:** New pure module `limit_warning.py` computes breached windows from an `AccountSnapshot`. `indicator.py` wires it in at two independent points: `_check_limit_warning` (event-driven, called from `_apply_snapshot` when a default account's snapshot changes — owns notification dedup) and `_update_warning_icon` (state-driven, called from `_refresh_all_timer` every 60s plus once on manual default-switch — owns the icon swap). A new warning SVG asset is installed alongside the existing icon.

**Tech Stack:** Python, GTK3/AyatanaAppIndicator3 (via `gi`), pytest.

Spec: `docs/specs/2026-07-03-limit-warning-icon-design.md`

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|----------------|----------------------|
| 1 | Task 1 | `limit_warning.py`, `tests/test_limit_warning.py` | single task |
| 2 | Task 2, Task 3 | `tests/test_indicator.py` (Task 2 only), `icons/codex-account-switcher-warning.svg` + `install.py` (Task 3 only) | ✅ no file overlap |
| 3 | Task 4 | `indicator.py`, `tests/test_indicator.py` | single task (depends on 1, 2, 3) |

Task 2 (test double) and Task 3 (icon asset + install.py) touch disjoint files and have no semantic dependency on each other — both only need Task 1's module to exist conceptually (neither imports it). Task 4 depends on all three: it imports `limit_warning` (Task 1), extends the same `_FakeAppIndicatorInstance` class Task 2 modified, and references `APPINDICATOR_ICON_WARNING` matching the asset Task 3 installs.

`meta.scheduler`: `dag-parallel` (Wave 2 has 2 independent file-disjoint tasks).

## Decision Enumeration

No task in this plan requires a human decision. All behavior, thresholds, file paths, and literals are fully pinned by the spec (§1–§8); no irreversible/fork/input/policy/architecture choice remains open. No `gated` records authored.

---

### Task 1: `limit_warning.py` — pure breach-detection module

**Wave:** 1
**Blocks:** Task 4
**Blocked by:** —

**Files:**
- Create: `limit_warning.py` — pure, GTK-free breach-detection logic
- Test: `tests/test_limit_warning.py`

**Contract (pin EXACTLY):**
```python
from health_client import AccountSnapshot, HealthStatus

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]: ...
```

**Behavior:**
- Returns the subset of `{WINDOW_5H, WINDOW_7D}` that is breached.
- 5h check: only evaluated if `snapshot.status == HealthStatus.OK` and `snapshot.primary_used_pct is not None`. Breached iff `100 - min(max(snapshot.primary_used_pct, 0), 100) < FIVE_HOUR_WARNING_THRESHOLD_PCT` (strict `<`; exactly-15%-remaining is NOT breached).
- 7d check: same shape, using `snapshot.secondary_used_pct` vs `SEVEN_DAY_WARNING_THRESHOLD_PCT`.
- `status != HealthStatus.OK` (i.e. `BROKEN` or `UNKNOWN`) → returns `frozenset()` regardless of percentages (do not evaluate either window).
- Pure function: no I/O, no GTK/GLib import, no side effects.

**Acceptance:**
- Run: `python -m pytest tests/test_limit_warning.py -v`
- Expected: PASS on a table covering — both windows breached; only 5h breached; only 7d breached; neither breached; `primary_used_pct` exactly yielding 15% remaining (not breached, boundary is strict `<`); `secondary_used_pct` exactly yielding 5% remaining (not breached); `status=BROKEN` with low percentages (not breached); `status=UNKNOWN` with low percentages (not breached); `primary_used_pct=None` (5h not evaluated, 7d still can be); `secondary_used_pct=None` (7d not evaluated, 5h still can be).

- [ ] Write tests covering the behavior above (implementer writes the test code)
- [ ] Implement `limit_warning.py` to satisfy the contract + acceptance
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add limit_warning.py tests/test_limit_warning.py && git commit -m "feat: add limit_warning breach-detection module"`

---

### Task 2: Test double — `set_icon_full` on `_FakeAppIndicatorInstance`

**Wave:** 2
**Blocks:** Task 4
**Blocked by:** —

**Files:**
- Modify: `tests/test_indicator.py:191-220` — add a recording `set_icon_full` method to `_FakeAppIndicatorInstance`

**Contract (pin EXACTLY):**
- Add to `_FakeAppIndicatorInstance.__init__` (`tests/test_indicator.py:192-200`): `self.icon_full_calls: list[tuple[str, str]] = []`
- Add method: `def set_icon_full(self, icon_name: str, description: str) -> None: self.icon_full_calls.append((icon_name, description))`

**Behavior:** No test logic here — this is a recording stub matching the existing style of `set_status`/`set_menu`/`set_title` in the same class (`tests/test_indicator.py:202-209`). No other class in the file (`_IndicatorWithoutActivate` at line 737, or the fakes at lines 2269/2350) needs this method — those are separate fakes for unrelated tests.

**Acceptance:**
- Run: `python -m pytest tests/test_indicator.py -k "FakeAppIndicator" -v` (or simply confirm no collection errors: `python -m pytest tests/test_indicator.py --collect-only -q`)
- Expected: PASS / clean collection — this task adds no new test cases of its own, Task 4 consumes `icon_full_calls`.

- [ ] Add `icon_full_calls` list and `set_icon_full` method to `_FakeAppIndicatorInstance`
- [ ] Run `python -m pytest tests/test_indicator.py --collect-only -q`, verify no collection errors
- [ ] Commit: `git add tests/test_indicator.py && git commit -m "test: add set_icon_full recorder to fake app indicator"`

---

### Task 3: Warning icon asset + `install.py` wiring

**Wave:** 2
**Blocks:** Task 4
**Blocked by:** —

**Files:**
- Create: `icons/codex-account-switcher-warning.svg` — warning-state variant of the existing tray icon
- Modify: `install.py:14-15` — new filename/template constants
- Modify: `install.py:56-58` (`install()`) — copy the new icon
- Modify: `install.py:76-79` (`uninstall()`) — remove the new icon

**Contract (pin EXACTLY):**
```python
# install.py, alongside existing ICON_FILENAME/ICON_TEMPLATE (line 14-15)
ICON_WARNING_FILENAME = "codex-account-switcher-warning.svg"
ICON_WARNING_TEMPLATE = PROJECT_ROOT / "icons" / ICON_WARNING_FILENAME
```

**Behavior:**
- `icons/codex-account-switcher-warning.svg`: same visual mark as `icons/codex-account-switcher.svg`, warning color treatment (e.g. amber/red accent) — exact visual design is an implementer judgment call, no spec-mandated palette. Must be a valid standalone SVG file (openable/renderable), matching the existing icon's viewbox/dimensions so it drops into the same hicolor `scalable/apps` slot.
- `install()` (`install.py:30-59`): after the existing `_ensure_copied_file(ICON_TEMPLATE, icon_target)` call (line 57), add `_ensure_copied_file(ICON_WARNING_TEMPLATE, dest_icons / ICON_WARNING_FILENAME)` — identical pattern, same `dest_icons` directory, before `_refresh_icon_cache(dest_icons)` (line 58).
- `uninstall()` (`install.py:62-79`): inside the existing `if dest_icons.exists(): ...` block (lines 76-79), after `_remove_copied_file(ICON_TEMPLATE, dest_icons / ICON_FILENAME)`, add `_remove_copied_file(ICON_WARNING_TEMPLATE, dest_icons / ICON_WARNING_FILENAME)` — identical pattern.
- No changes to `_ensure_copied_file`/`_remove_copied_file`/`_refresh_icon_cache` themselves — reuse as-is.

**Acceptance:**
- Run: `python -m pytest tests/ -k install -v` (repo's existing install tests, if any — confirms no regression) and manually: `python -c "from pathlib import Path; import install; report = install.install(dest_bin=Path('/tmp/t-bin'), dest_apps=Path('/tmp/t-apps'), dest_icons=Path('/tmp/t-icons')); print((Path('/tmp/t-icons')/install.ICON_WARNING_FILENAME).exists())"`
- Expected: existing install tests still PASS; manual check prints `True`.

- [ ] Create `icons/codex-account-switcher-warning.svg`
- [ ] Add `ICON_WARNING_FILENAME`/`ICON_WARNING_TEMPLATE` constants and wire into `install()`/`uninstall()`
- [ ] Run acceptance check → expected output above
- [ ] Commit: `git add icons/codex-account-switcher-warning.svg install.py && git commit -m "feat: add warning icon asset and install.py wiring"`

---

### Task 4: Wire breach detection into `Indicator`

**Wave:** 3
**Blocks:** —
**Blocked by:** Task 1, Task 2, Task 3

**Files:**
- Modify: `indicator.py:33-40` — add `APPINDICATOR_ICON_WARNING` constant
- Modify: `indicator.py:21-27` — add `limit_warning` import
- Modify: `indicator.py:146-151` — add `self._limit_breach_state` and `self._icon_warning_active` instance state
- Modify: `indicator.py:856-874` (`_apply_snapshot`) — call `self._check_limit_warning(account, merged_snapshot)` inside the `if merged_snapshot is not None:` block
- Modify: `indicator.py:827-829` (`_refresh_all_timer`) — call `self._update_warning_icon()`
- Modify: `indicator.py:309-327` (`on_activate` closure inside `_make_activate_handler`) — call `self._update_warning_icon()` after `target_registry.set_default(account)`
- Modify: `indicator.py` — new methods `_check_limit_warning`, `_update_warning_icon`
- Test: `tests/test_indicator.py`

**Contract (pin EXACTLY):**
```python
APPINDICATOR_ICON_WARNING = os.environ.get(
    "CODEX_TRAY_ICON_WARNING_NAME",
    "codex-account-switcher-warning",
)

# instance state, alongside self.snapshots (indicator.py:146)
self._limit_breach_state: dict[str, frozenset[str]] = {}
self._icon_warning_active: bool = False

def _check_limit_warning(self, account: Account, merged_snapshot: AccountSnapshot) -> None: ...
def _update_warning_icon(self) -> None: ...
```
Import: `import limit_warning` at top of `indicator.py`, alongside the existing `from tray_model import (...)` block (`indicator.py:21-27`) — module-level import, referenced as `limit_warning.breached_windows`, `limit_warning.WINDOW_5H`, `limit_warning.WINDOW_7D`.

**Behavior — `_check_limit_warning(self, account, merged_snapshot)`:**
- Called from `_apply_snapshot` (`indicator.py:856-874`) as the **last statement inside** `if merged_snapshot is not None:` (i.e. right after `self.snapshots[key] = merged_snapshot` at line 866, still before `self._update_account_menu_item(account)` at line 867 — order between these two doesn't matter functionally, but `_check_limit_warning` must execute before the function reaches the `if generation is not None: ...; return` branch at lines 870-872, since the scheduled-refresh/timer path always takes that early return).
- Resolves registry: `registry = self.registry if account.tool == self._registry_tool(self.registry) else self.claude_registry`.
- No-ops (returns immediately, no notify, no icon call) unless `registry is not None and registry.default_slug() == account.slug`.
- `key = self._account_key(account)`.
- `new_breached = limit_warning.breached_windows(merged_snapshot)`.
- `old_breached = self._limit_breach_state.get(key, frozenset())`.
- `newly_breached = new_breached - old_breached`.
- For each `window` in `newly_breached` (order: `WINDOW_5H` before `WINDOW_7D` if both): call `self._notify(message)` once per window, where `message` identifies tool + account alias + window + remaining percent, e.g. for 5h: `f"{account.tool.capitalize()} account '{account.alias}' is under 15% remaining on its 5h limit ({remaining}% left)"`, where `remaining = 100 - min(max(merged_snapshot.primary_used_pct, 0), 100)` for 5h (`secondary_used_pct` / `SEVEN_DAY_WARNING_THRESHOLD_PCT`'s "5%"/"7d" for the 7d case, using `secondary_used_pct`). Reuse `limit_warning.FIVE_HOUR_WARNING_THRESHOLD_PCT`/`SEVEN_DAY_WARNING_THRESHOLD_PCT` for the threshold number in the message rather than hardcoding `15`/`5` twice.
- `self._limit_breach_state[key] = new_breached` (always update, even if `newly_breached` is empty — this is what lets a later recovery-then-re-breach notify again).
- Finally: `self._update_warning_icon()` (unconditional, idempotent).

**Behavior — `_update_warning_icon(self)`:**
- `if self.indicator is None: return`.
- `warning = False`.
- For each `registry` in `[self.registry] + ([self.claude_registry] if self.claude_registry is not None else [])`: `account = self._default_account(registry)`; if `account is not None`: `snapshot = self.snapshots.get(self._account_key(account))`; if `snapshot is not None and limit_warning.breached_windows(snapshot)`: `warning = True`.
- If `warning != self._icon_warning_active`:
  - If `warning`: `if hasattr(self.indicator, "set_icon_full"): self.indicator.set_icon_full(APPINDICATOR_ICON_WARNING, "Usage limit warning")`
  - Else: `if hasattr(self.indicator, "set_icon_full"): self.indicator.set_icon_full(APPINDICATOR_ICON, APP_DISPLAY_NAME)`
  - `self._icon_warning_active = warning`

**Behavior — call sites:**
- `_refresh_all_timer` (`indicator.py:827-829`): add `self._update_warning_icon()` call — either before or after the existing `self._refresh_all(force=True)` line, before `return True`.
- `on_activate` closure (`indicator.py:309-327`): add `self._update_warning_icon()` immediately after `target_registry.set_default(account)` (line 322), before `self._update_title()`.

**Acceptance:**
- Run: `python -m pytest tests/test_indicator.py -v`
- Expected: PASS, including new cases:
  1. Default account's snapshot crosses into 5h breach (via `_apply_snapshot`) → exactly one `_notify`/`subprocess.run` call with a message containing `"5h"`, and `fake_indicator.icon_full_calls[-1][0] == indicator.APPINDICATOR_ICON_WARNING`.
  2. Same account gets a second snapshot still breached on 5h → no additional notify call (dedup holds); `_limit_breach_state` unchanged in breach content.
  3. Breached account recovers (next snapshot back above threshold) → no notify call on recovery; `icon_full_calls[-1][0] == indicator.APPINDICATOR_ICON` (back to normal).
  4. A non-default account's snapshot breaches → no notify call, no icon call (`_check_limit_warning` no-ops before computing anything).
  5. `self.claude_registry is None` → calling `_update_warning_icon()` directly does not raise.
  6. Calling `on_activate` (via `_make_activate_handler`) to switch default account applies `_update_warning_icon()` synchronously (assert `icon_full_calls` grew) without needing `_refresh_all_timer` to fire.

- [ ] Write tests covering the 6 cases above (implementer writes the test code)
- [ ] Implement `_check_limit_warning`, `_update_warning_icon`, constant, import, instance state, and the three call sites
- [ ] Run acceptance check → expected output above
- [ ] Run full suite: `python -m pytest -v` (confirm no regressions elsewhere)
- [ ] Commit: `git add indicator.py tests/test_indicator.py && git commit -m "feat: wire limit-warning breach detection into Indicator"`

---

## Architecture Decisions

(Carried from spec — no plan-level changes.)

- `limit_warning.py` is a separate pure module: passes the deletion test (would scatter GTK-coupled or menu-formatting-coupled logic otherwise); justified single-adapter seam isolating testable calculation from I/O.
- No second GLib timer: `_check_limit_warning` piggybacks on `_apply_snapshot`'s existing event; `_update_warning_icon`'s heartbeat reuses the existing `_refresh_all_timer`, not a new timer.
- No badge/overlay icon: full `set_icon_full` swap matches existing single-static-SVG infrastructure; no new compositing machinery.
