chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
---
|
||||
summary: "Design spec for Kilo organization-scoped usage fetching, settings, and menu rendering."
|
||||
read_when:
|
||||
- Designing or implementing Kilo organization support
|
||||
- Changing Kilo scoped usage request headers
|
||||
- Updating Kilo organization settings persistence
|
||||
---
|
||||
|
||||
# Kilo Organization Selection & Usage — Design
|
||||
|
||||
**Status:** approved
|
||||
**Date:** 2026-05-11
|
||||
**Owner:** noefabris
|
||||
|
||||
## Problem
|
||||
|
||||
The Kilo provider in CodexBar always queries the personal account. Users who belong to one or more Kilo organizations cannot see organization-level credits, KiloPass usage, or plan info. Kilo's own clients (VS Code extension, CLI) let users pick "Personal" or any organization they belong to and route requests with an `X-KILOCODE-ORGANIZATIONID` header.
|
||||
|
||||
Goal: let CodexBar users opt in to seeing one or more Kilo organizations alongside their personal account.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Menu-bar org switcher. Selection lives in Preferences only.
|
||||
- Editing org membership from CodexBar (read-only consumer of Kilo's org list).
|
||||
- Per-org auth (CodexBar reuses the same API key/CLI session; Kilo's gateway scopes via header).
|
||||
- Replacing the existing single-account `Personal` flow when no orgs are configured.
|
||||
|
||||
## Constraints / context
|
||||
|
||||
- Kilo gateway accepts `X-KILOCODE-ORGANIZATIONID: <orgId>` to scope any authenticated request. Documented at `kilo.ai/docs/gateway/authentication`.
|
||||
- Profile endpoint shape (from `Kilo-Org/kilocode` `packages/kilo-gateway/src/api/profile.ts`):
|
||||
`GET /api/profile` → `{ user: { email, name }, organizations: [{ id, name, role }] }`.
|
||||
- CodexBar's Kilo provider currently calls `https://app.kilo.ai/api/trpc` with procedures `user.getCreditBlocks`, `kiloPass.getState`, `user.getAutoTopUpPaymentMethod`. The `X-KILOCODE-ORGANIZATIONID` header is transport-level and must work for the same procedures.
|
||||
- Existing CodexBar patterns to reuse:
|
||||
- `ProviderIdentitySnapshot.accountOrganization` for rendering the org name in a card.
|
||||
- Stacked multi-snapshot rendering used by Claude tokenAccounts (Preferences → Advanced → Display).
|
||||
- `~/.codexbar/config.json` per-provider entry for persisted state.
|
||||
|
||||
## User stories
|
||||
|
||||
1. **As a user with no orgs**, the Kilo card looks exactly as it does today. No new UI noise.
|
||||
2. **As a user with one or more orgs**, I can open Preferences → Providers → Kilo, hit "Refresh organizations", see my org list, and tick the orgs I want to monitor. Each enabled org appears as its own card stacked with Personal in the menu.
|
||||
3. **As a user whose API key isn't set**, the Organizations section explains I need the key first and the Refresh button is disabled.
|
||||
4. **As a user using CLI source mode**, org selection still works — the header is sent on every fetch regardless of where the bearer token came from.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data model
|
||||
|
||||
- `KiloOrganization` (Sendable, Codable, Equatable) in `Sources/CodexBarCore/Providers/Kilo/`:
|
||||
- `id: String`
|
||||
- `name: String`
|
||||
- `role: String?` (optional; treated as display-only)
|
||||
- `KiloUsageScope` (Sendable, Hashable) in same module:
|
||||
- `.personal`
|
||||
- `.organization(id: String, name: String)`
|
||||
- A `scopeIdentifier` computed property: `"personal"` or `"org:<id>"` used as the snapshot map key.
|
||||
- `KiloUsageSnapshot` gains `scope: KiloUsageScope` (default `.personal` keeps existing call sites compiling).
|
||||
|
||||
### API layer (`KiloUsageFetcher`)
|
||||
|
||||
- Existing `fetchUsage(apiKey:environment:)` becomes:
|
||||
`fetchUsage(apiKey:scope:environment:)` with `scope: KiloUsageScope = .personal`.
|
||||
- When `.organization(id, _)`: set request header `X-KILOCODE-ORGANIZATIONID: id` on the existing tRPC batch URLRequest. Everything else unchanged.
|
||||
- New `fetchOrganizations(apiKey:environment:)` → `[KiloOrganization]`:
|
||||
- Primary: tRPC batch call to `user.getOrganizations` against `https://app.kilo.ai/api/trpc`. Parse the same payload-context shape as other procedures (defensive against schema drift).
|
||||
- Fallback: if tRPC returns 404 / endpoint not found, fall back to `GET https://api.kilo.ai/api/profile` and read `data.organizations`. Both endpoints are part of the documented Kilo Gateway.
|
||||
- Returns `[]` (not error) when the user has no orgs.
|
||||
- Maps `401/403 → KiloUsageError.unauthorized`, `404 → endpointNotFound`, etc., using the existing `statusError(for:)`.
|
||||
|
||||
### Settings
|
||||
|
||||
`SettingsStore` extension (new file `SettingsStore+Kilo.swift` or extend `KiloSettingsStore.swift`):
|
||||
|
||||
- `kiloKnownOrganizations: [KiloOrganization]` — cache of organizations last fetched. Survives restart.
|
||||
- `kiloEnabledOrganizationIDs: Set<String>` — which orgs the user wants to fetch + render.
|
||||
- Personal scope is implicit: always enabled, can't be toggled off.
|
||||
|
||||
Persistence:
|
||||
- Add `organizations: [KiloOrganization]?` and `enabledOrganizationIds: [String]?` to the Kilo provider's entry in `~/.codexbar/config.json`.
|
||||
- Mutators write through `updateProviderConfig(provider: .kilo)`.
|
||||
|
||||
### Refresh / UsageStore
|
||||
|
||||
- `UsageStore`'s Kilo refresh path computes the active scope list: `[.personal] + enabled orgs from kiloKnownOrganizations`.
|
||||
- Fan-out using a `TaskGroup`, one child per scope, each calling `KiloUsageFetcher.fetchUsage(apiKey:scope:)`.
|
||||
- Per-scope failures isolated: a 403 on one org sets that scope's error state but does not affect personal or other orgs.
|
||||
- Snapshots stored in a new dictionary `kiloScopedSnapshots: [String: KiloUsageSnapshot]` keyed by `scope.scopeIdentifier`, alongside the existing single-snapshot field. When `kiloScopedSnapshots` has more than one entry the menu uses stacked rendering; otherwise existing single-card rendering.
|
||||
|
||||
### UI — Preferences → Providers → Kilo
|
||||
|
||||
- New section header "Organizations" placed below the API key field.
|
||||
- Row 1 (always): `Personal account` with a disabled-on checkbox.
|
||||
- Rows 2..N: each known organization with a togglable checkbox `[✓] <Org name> — <role>`.
|
||||
- Empty state when `kiloKnownOrganizations` is empty: "No organizations loaded. Click Refresh after setting your API key."
|
||||
- "Refresh organizations" button:
|
||||
- Disabled when API key is empty.
|
||||
- Calls `KiloUsageFetcher.fetchOrganizations(apiKey:)` on a background task.
|
||||
- On success: writes to `kiloKnownOrganizations`, prunes `kiloEnabledOrganizationIDs` entries that no longer exist.
|
||||
- On 401/403: surface inline error "API key unauthorized. Refresh or update it."
|
||||
- On network error: surface inline error.
|
||||
|
||||
### Menu rendering
|
||||
|
||||
- The Kilo card renderer reads `kiloScopedSnapshots`. When count > 1, render each as a stacked card using the same vertical layout already used by Claude's stacked tokenAccount snapshots.
|
||||
- Each scope's card sets `ProviderIdentitySnapshot.accountOrganization`:
|
||||
- `.personal` → `"Personal"`
|
||||
- `.organization(_, name)` → `name`
|
||||
- Existing credit/pass/plan rows render unchanged inside each card.
|
||||
|
||||
### Errors / edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| User has no orgs | Personal scope only. Org section shows empty state. No fan-out. |
|
||||
| API key missing | Refresh button disabled. Existing missing-credentials error still surfaces on usage fetch. |
|
||||
| Org refresh fails (401/403) | Inline error in Preferences; keep cached list. |
|
||||
| Org usage fetch returns 403 | That org's card shows a small error label; personal + other orgs render normally. |
|
||||
| Org removed on Kilo side | Next Refresh drops it from `kiloKnownOrganizations`; if it was in `kiloEnabledOrganizationIDs` it is silently pruned. |
|
||||
| CLI source mode | Header sent same way. If the resolved CLI bearer can't scope to that org, it 403s — handled by the per-scope error case above. |
|
||||
| Source = `auto` | Org scope follows the same auto fallback; failures inside the scope respect the fallback chain. |
|
||||
| Multiple orgs enabled | Concurrent fetch; per-scope timeouts isolated. |
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests (in `Tests/CodexBarTests`):
|
||||
- `KiloUsageFetcherTests`:
|
||||
- Header injection: `.organization(id, _)` → request contains `X-KILOCODE-ORGANIZATIONID: id`. `.personal` → no header.
|
||||
- `fetchOrganizations` parses both tRPC shape and `/api/profile` REST shape.
|
||||
- 401/403 → `.unauthorized`.
|
||||
- `KiloSettingsStoreTests`:
|
||||
- Round-trip of `kiloKnownOrganizations` and `kiloEnabledOrganizationIDs` through config.json.
|
||||
- Pruning of stale IDs when known list shrinks.
|
||||
- `UsageStore+KiloRefreshTests`:
|
||||
- Fan-out runs N scopes, per-scope error isolation.
|
||||
- Single-scope path unchanged when no orgs enabled.
|
||||
- CLI snapshot test (`Tests/CodexBarTests/CLIRendererTests` or similar): `codexbar-cli kilo` shows one section per active scope when orgs are enabled.
|
||||
- Run `make check` and `swift test` before handoff.
|
||||
|
||||
## Migration / compatibility
|
||||
|
||||
- `KiloUsageSnapshot.scope` defaults to `.personal` — all existing call sites compile unchanged.
|
||||
- Config additions are optional — old configs continue to load as personal-only.
|
||||
- No new dependencies.
|
||||
|
||||
## Open items (verify during build)
|
||||
|
||||
- Confirm `https://app.kilo.ai/api/trpc/user.getOrganizations` exists. If not, the REST fallback to `https://api.kilo.ai/api/profile` is the path of record.
|
||||
- Confirm the tRPC procedures honor `X-KILOCODE-ORGANIZATIONID`. Documented for gateway; spot-check during integration.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Menu-bar org switcher UI.
|
||||
- Per-org token storage / multiple API keys for the same provider.
|
||||
- CLI command for org switching (`codexbar-cli` consumes the same settings).
|
||||
- Widget rendering changes for multi-scope (widget continues to show personal scope).
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
summary: "Decision brief for pace and reserve reporting on Xiaomi MiMo token plans."
|
||||
read_when:
|
||||
- Designing MiMo reserve or deficit text
|
||||
- Changing MiMo token-plan period mapping
|
||||
---
|
||||
|
||||
# MiMo reserve reporting
|
||||
|
||||
**Status:** accepted fail-closed evidence contract; not implemented
|
||||
**Issue:** [#1205](https://github.com/steipete/CodexBar/issues/1205)
|
||||
**Date:** 2026-07-01
|
||||
|
||||
## Problem
|
||||
|
||||
CodexBar shows MiMo token usage and the plan's current period end, but does not show whether consumption is in reserve or
|
||||
deficit. Shared pace math requires both the used percentage and a trustworthy window duration. MiMo's observed plan
|
||||
response supplies the former and an end timestamp, but not the period start, duration, or billing cadence.
|
||||
|
||||
## Verified constraints
|
||||
|
||||
- MiMo sells monthly and annual token plans. Plan validity and token-credit reset cadence are not interchangeable.
|
||||
- The redacted live response documented in [#1205](https://github.com/steipete/CodexBar/issues/1205) includes plan code,
|
||||
plan name, current period end, expiry, and auto-renew fields. It does not include a start timestamp, duration, or
|
||||
monthly/annual cadence discriminator.
|
||||
- The usage response provides token used, limit, and percentage. Current main therefore maps a primary `RateWindow` with
|
||||
`resetsAt` and `windowMinutes == nil`.
|
||||
- Shared pace calculations intentionally return no reserve/deficit result when window duration is unknown.
|
||||
- Closed PR [#1310](https://github.com/steipete/CodexBar/pull/1310) inferred 30 or 31 days from the period end. That
|
||||
misclassifies annual plans during their final month and treats calendar proximity as a data contract.
|
||||
- Shared pace presentation owns reserve/deficit calculations. MiMo must not bypass it with provider-specific math.
|
||||
- PR [#1565](https://github.com/steipete/CodexBar/pull/1565) concerns MiMo cookie import only; it does not supply
|
||||
missing cadence data.
|
||||
|
||||
## Options
|
||||
|
||||
### A. Wait for an authoritative window — accepted
|
||||
|
||||
Keep `windowMinutes` nil. Enable reserve text only when a documented response supplies period start/duration, an
|
||||
unambiguous cadence, or a separate token-credit reset contract.
|
||||
|
||||
Benefits: correct for monthly and annual plans, preserves shared pace semantics, and avoids confident but wrong reserve
|
||||
text. Cost: #1205 remains visibly unsupported until upstream evidence exists.
|
||||
|
||||
### B. Infer one month from the reset timestamp
|
||||
|
||||
Set 30 or 31 days based on the current date and period end.
|
||||
|
||||
Rejected: a reset's proximity does not prove its duration. It fails for annual validity, final-month annual plans,
|
||||
calendar-month boundaries, delayed refreshes, and reset schedules that differ from billing validity.
|
||||
|
||||
### C. Add a user-selected cadence
|
||||
|
||||
Let users choose monthly or annual in Preferences and derive a duration locally.
|
||||
|
||||
Not recommended: this adds persistent provider configuration, still cannot prove whether credits reset monthly within an
|
||||
annual plan, and makes incorrect reserve text look authoritative.
|
||||
|
||||
### D. Learn duration from consecutive observations
|
||||
|
||||
Persist prior reset timestamps and infer the next duration after a rollover.
|
||||
|
||||
Not recommended as the initial contract: it withholds pace until a rollover, fails when the service changes or extends a
|
||||
period, and turns historical coincidence into authority. It could become a separately approved heuristic with explicit
|
||||
confidence and expiry rules.
|
||||
|
||||
## Accepted contract
|
||||
|
||||
1. Preserve MiMo's used percentage and `resetsAt` exactly as received.
|
||||
2. Keep `windowMinutes` nil while cadence is unknown; shared reserve/deficit text stays absent.
|
||||
3. Never derive duration from days remaining, plan validity, plan price, or plan-name text.
|
||||
4. If upstream adds `periodStart`, compute duration from start to end and reject non-positive or implausible intervals.
|
||||
5. If upstream adds a cadence enum, map only documented values and leave unknown values nil.
|
||||
6. Annual-plan support needs explicit proof of the token-credit reset window, not only subscription expiry.
|
||||
7. Use shared `UsagePace` and menu-card presentation once the window is authoritative; no MiMo-specific reserve formula.
|
||||
8. Treat missing, malformed, contradictory, or undocumented period evidence identically: fail closed without reserve or
|
||||
deficit text.
|
||||
|
||||
## Proof required before implementation
|
||||
|
||||
- Redacted monthly-plan detail and usage responses.
|
||||
- Redacted annual-plan detail and usage responses outside and inside the final month.
|
||||
- Public documentation or repeated live evidence distinguishing subscription validity from token-credit reset cadence.
|
||||
- Packaged-app screenshots for monthly and annual plans showing correct reserve/deficit text and reset time.
|
||||
|
||||
## Acceptance tests
|
||||
|
||||
- Unknown cadence keeps `windowMinutes` and reserve text nil.
|
||||
- Monthly and annual fixtures cannot be distinguished from `planCode` or `planName` alone.
|
||||
- A proven start/end window maps to the shared pace model without provider-specific arithmetic.
|
||||
- Final-month annual and credit-reset fixtures do not become 30/31-day windows by proximity.
|
||||
- Invalid, reversed, or unknown period fields fail closed.
|
||||
- `make check` and `make test` pass on the exact implementation head.
|
||||
|
||||
## Decision
|
||||
|
||||
CodexBar accepts the fail-closed contract above. This decision does not authorize runtime inference or change current MiMo
|
||||
behavior: `windowMinutes` remains nil and reserve/deficit text remains absent until authoritative evidence satisfies the
|
||||
contract. Issue [#1205](https://github.com/steipete/CodexBar/issues/1205) stays open for that evidence and a separately
|
||||
reviewed implementation. The next useful input is an annual-plan payload plus an authoritative token-credit reset source.
|
||||
@@ -0,0 +1,106 @@
|
||||
---
|
||||
summary: "Decision brief for showing multiple OpenCode Go workspaces from one account."
|
||||
read_when:
|
||||
- Designing OpenCode Go multi-workspace usage
|
||||
- Changing OpenCode Go workspace discovery or menu rendering
|
||||
---
|
||||
|
||||
# OpenCode Go multi-workspace usage
|
||||
|
||||
**Status:** automatic fan-out accepted; not implemented
|
||||
**Issue:** [#1626](https://github.com/steipete/CodexBar/issues/1626)
|
||||
**Date:** 2026-07-01
|
||||
|
||||
## Problem
|
||||
|
||||
One OpenCode account can have several workspaces, each with its own Go subscription. CodexBar discovers workspace
|
||||
identifiers but selects only the first one, stores one optional workspace override, and projects one scalar usage
|
||||
snapshot. Users must replace the override and refresh to inspect another workspace.
|
||||
|
||||
This is not multi-account support: the browser session is shared. Workspace identity scopes the usage request and the
|
||||
rendered result.
|
||||
|
||||
## Verified constraints
|
||||
|
||||
- OpenCode Go subscriptions belong to workspaces. OpenCode's public Go documentation says one member per workspace may
|
||||
subscribe.
|
||||
- Current discovery parsing yields workspace identifiers only. A stable, authenticated response field or endpoint for
|
||||
display names still needs redacted live proof before names become a persisted contract.
|
||||
- The current snapshot, refresh state, settings field, CLI projection, and menu card are single-workspace.
|
||||
- Merged PR [#1788](https://github.com/steipete/CodexBar/pull/1788) made the weekly usage window optional. Multi-workspace
|
||||
projection must preserve that rolling-only result shape independently for every workspace.
|
||||
- Shared token-account rows are the wrong identity model: separate workspace results reuse one credential.
|
||||
|
||||
## Options
|
||||
|
||||
### A. Automatic fan-out with stacked cards — accepted
|
||||
|
||||
Discover all workspaces on refresh, fetch them with the same authenticated session, and render one stacked card per
|
||||
workspace. Keep the existing workspace override as an explicit single-workspace filter for troubleshooting and large
|
||||
accounts.
|
||||
|
||||
Benefits: matches the request, requires no duplicated cookies, and follows the existing Kilo scoped-snapshot and stacked
|
||||
card patterns. Costs: refresh fan-out, partial-failure state, ordering, and a new workspace-scoped snapshot model.
|
||||
|
||||
### B. Settings-selected workspaces
|
||||
|
||||
Add a discovery/selection list in Preferences and fetch only checked workspaces.
|
||||
|
||||
Benefits: bounded network work and explicit control. Costs: cached workspace metadata, stale-selection handling, more
|
||||
setup, and a surprising default for a user expecting all subscriptions to appear.
|
||||
|
||||
### C. Workspace submenu
|
||||
|
||||
Show one provider card and put workspace results in a submenu.
|
||||
|
||||
Benefits: compact menu. Costs: hides usage, adds provider-specific navigation, and does not reuse the shared stacked-card
|
||||
presentation.
|
||||
|
||||
## Accepted contract
|
||||
|
||||
Choose option A with these boundaries:
|
||||
|
||||
1. Add `OpenCodeGoWorkspace` with an identifier and optional display name. Never use a name as a request key.
|
||||
2. Discover once per refresh, normalize and deduplicate identifiers, then sort by normalized identifier. Response timing,
|
||||
server order, and display-name changes must not reorder cards.
|
||||
3. Process no more than 20 discovered workspaces per refresh and no more than 4 workspace pipelines concurrently. If
|
||||
discovery returns more, show that results are truncated and direct the user to the single-workspace override.
|
||||
4. Isolate results per workspace. One workspace failure must not erase or relabel successful siblings; if a previous
|
||||
snapshot is retained, mark it stale and associate the current error with the same stable identifier.
|
||||
5. Store workspace-scoped snapshots separately from token accounts. Project a safe workspace label through provider
|
||||
identity for stacked-card and CLI output, but keep the identifier as the association key.
|
||||
6. Preserve the existing override as a single-workspace filter. When set, skip discovery and fetch exactly that normalized
|
||||
identifier. Invalid input fails before networking; request failure never falls back to another workspace.
|
||||
7. Reuse the one in-memory authenticated session for every workspace pipeline. Never copy or persist cookies per
|
||||
workspace, and never include raw credentials or workspace identifiers in logs.
|
||||
8. If the live contract exposes no stable display name, show a short redacted ordinal or identifier suffix and defer name
|
||||
persistence. Persisting names requires separate authenticated contract proof.
|
||||
9. Keep workspace data inside the OpenCode Go provider. Do not reuse identity or plan fields from another provider.
|
||||
|
||||
## Proof required before implementation
|
||||
|
||||
- Redacted authenticated discovery response proving stable workspace identifier and name fields, or proving names are
|
||||
unavailable.
|
||||
- Redacted usage responses from two workspaces under one session.
|
||||
- Packaged-app screenshot showing two stacked cards with no account or workspace secrets.
|
||||
- Failure proof showing one workspace can fail while another remains visible.
|
||||
|
||||
## Acceptance tests
|
||||
|
||||
- Discovery deduplicates two or more workspace identifiers and handles missing names.
|
||||
- Shared credentials produce one request per workspace without persisting duplicate cookies.
|
||||
- Results stay associated with their workspace when responses complete out of order.
|
||||
- Partial failures preserve successful sibling cards.
|
||||
- More than 20 discovered workspaces produce 20 deterministic results plus a visible truncation state.
|
||||
- At most four workspace pipelines run concurrently.
|
||||
- Manual override fetches only the requested workspace.
|
||||
- Invalid overrides fail before any network request; valid failed overrides do not fall back.
|
||||
- CLI JSON and menu models label every workspace deterministically.
|
||||
- `make check` and `make test` pass on the exact implementation head.
|
||||
|
||||
## Decision
|
||||
|
||||
CodexBar accepts automatic workspace fan-out with stacked cards and the existing single-workspace override, bounded by the
|
||||
contract above. This document does not change runtime behavior. Implementation still requires redacted authenticated
|
||||
multi-workspace proof, focused parser/model tests, packaged UI proof, and separate review. Workspace-name persistence
|
||||
remains out of scope until the authenticated response contract is proven.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
summary: "Accepted design for opt-in predictive pace warning notifications."
|
||||
read_when:
|
||||
- Reviewing or implementing predictive quota notifications
|
||||
- Changing pace-driven notification cooldown or recovery behavior
|
||||
---
|
||||
|
||||
# Predictive pace warning notifications
|
||||
|
||||
**Status:** implemented by #1960 and released in v0.42.0
|
||||
**Date:** 2026-07-01
|
||||
**Issue:** #1299
|
||||
|
||||
## Decision
|
||||
|
||||
CodexBar may add the bounded, default-off warning below in a separate implementation PR. Send one alert per risk
|
||||
episode, not hourly reminders. Re-arm only after a successful, authoritative observation says the quota will last until
|
||||
reset. Keep the existing pace model as the only forecast authority and keep episode state in memory. Do not add
|
||||
configurable thresholds or additional provider scope in the first version.
|
||||
|
||||
## Why this is a decision, not an implementation PR
|
||||
|
||||
`VISION.md` requires sign-off for new features. The trigger mechanics are straightforward, but notification noise, provider scope, cooldown behavior, and default state are product choices. PR #1789 already changes pace presentation, historical confidence settings, and localization; combining proactive notifications with it would make both decisions harder to review.
|
||||
|
||||
## Accepted behavior
|
||||
|
||||
### Scope and default
|
||||
|
||||
- Add one preference: **Predictive pace warnings**, default off.
|
||||
- Evaluate Codex and Claude only.
|
||||
- Evaluate session and weekly windows only.
|
||||
- Do not change existing threshold or depleted/restored notifications.
|
||||
|
||||
### Eligibility
|
||||
|
||||
Evaluate only after a provider refresh completed successfully and produced a current `UsagePace` for the window. A window is warning-eligible only when all conditions hold:
|
||||
|
||||
- `willLastToReset == false`.
|
||||
- `etaSeconds` exists and is greater than zero.
|
||||
- If `runOutProbability` exists, it is at least `0.5`.
|
||||
- The provider, account, and window are all identifiable enough to form a stable in-memory key.
|
||||
|
||||
A missing probability does not suppress a warning because linear/workday pace does not produce one. A probability below `0.5` does suppress it because the historical model is explicitly reporting low confidence.
|
||||
|
||||
### State machine
|
||||
|
||||
Key state by provider, stable account discriminator, and window. Do not share state across accounts.
|
||||
|
||||
The key must include the reset-window identity so a new quota window cannot inherit the previous window's warning state.
|
||||
For each key:
|
||||
|
||||
1. First eligible observation may notify; this is already a forecast derived from history/current-window progress, not an ordinary percentage transition.
|
||||
2. After notifying, suppress every later observation while that risk episode remains active. Elapsed time alone never
|
||||
permits a repeat.
|
||||
3. A successful observation where the pace recovers (`willLastToReset == true`) re-arms the key. A later relapse may
|
||||
notify once.
|
||||
4. Low-confidence risk, missing pace, missing window, failed refresh, or incomplete provider enrichment neither notifies
|
||||
nor counts as recovery. Preserve current state until a successful, authoritative observation arrives.
|
||||
A synthetic placeholder for an unreported quota window is also non-authoritative and must preserve state.
|
||||
5. A new reset-window identity starts with fresh state. Prune expired window keys rather than carrying episode state
|
||||
across resets.
|
||||
6. Keep this state in memory only. App restart resets episodes; do not add persisted notification history.
|
||||
|
||||
### Copy and privacy
|
||||
|
||||
Use the existing notification delivery path and sound preference. Suggested copy:
|
||||
|
||||
- Title: `<Provider> <window> pace warning`
|
||||
- Body without visible identity: `At the current pace, this quota may run out in <duration>, before it resets.`
|
||||
- Body with visible identity: prefix the existing redaction-aware account label.
|
||||
|
||||
When personal information is hidden, do not include email, organization, workspace, plan, or other identity fields. Account discrimination may remain local internal state but must not be logged or rendered as notification text.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Default-on or opt-out warnings.
|
||||
- Providers other than Codex and Claude.
|
||||
- Daily, monthly, credits, spend, or extra rate windows.
|
||||
- New probability or ETA modeling.
|
||||
- User-configurable probability thresholds or cooldown duration.
|
||||
- Persistent warning history across launches.
|
||||
- Changes to #1789's headroom label, historical-week control, or pace model.
|
||||
|
||||
## Implementation seams after approval
|
||||
|
||||
- A pure evaluator/state reducer accepting provider, account discriminator, reset-window identity, pace, and prior state.
|
||||
- A small `UsageStore` integration after successful snapshot/pace calculation, separate from static threshold transition state.
|
||||
- One default-off `SettingsStore` value and one Notifications-pane control.
|
||||
- Localized notification title/body through the existing presenter.
|
||||
- No new dependency and no provider fetch changes.
|
||||
|
||||
## Required tests
|
||||
|
||||
- Default is off; migration does not silently enable existing users.
|
||||
- Codex/Claude session and weekly are eligible; all other provider/window combinations are ignored.
|
||||
- `willLastToReset == true`, missing/non-positive ETA, and probability below `0.5` do not notify.
|
||||
- Nil probability remains eligible.
|
||||
- Provider/account/window keys are isolated.
|
||||
- Same at-risk key never repeats merely because time passes.
|
||||
- A successful healthy observation re-arms the key; missing, low-confidence, and failed/incomplete observations do not.
|
||||
- A new reset-window identity has independent state and expired identities are pruned.
|
||||
- Restart/new evaluator has no persisted episode state.
|
||||
- Hidden-personal-info copy omits identity; visible copy uses only the provider-owned account label.
|
||||
- Existing threshold and depleted/restored notification tests remain unchanged and green.
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
- Default off limits surprise and notification fatigue, but reduces discovery.
|
||||
- One alert per risk episode minimizes notification fatigue, but a sustained forecast is not repeated until it first
|
||||
recovers and then relapses.
|
||||
- In-memory state is simple and privacy-preserving, but a relaunch can produce another warning.
|
||||
- Restricting scope to Codex and Claude leaves other providers out until their pace and account identity semantics are reviewed.
|
||||
|
||||
## Recorded product choice
|
||||
|
||||
Approved: default off; Codex/Claude; session/weekly; one alert per provider/account/reset-window risk episode;
|
||||
authoritative recovery re-arms; in-memory state. Hourly repeats are rejected.
|
||||
@@ -0,0 +1,240 @@
|
||||
---
|
||||
summary: "Decision-ready design for provider pins beside the merged icon and focus-aware merged-icon selection."
|
||||
read_when:
|
||||
- Implementing separate provider icons while Merge Icons is enabled
|
||||
- Implementing focus-aware provider presentation
|
||||
- Changing merged-icon provider precedence or status-item layout
|
||||
---
|
||||
|
||||
# Provider Presentation and Focus-Aware Icon — Design
|
||||
|
||||
**Status:** accepted on 2026-07-04
|
||||
**Date:** 2026-07-01
|
||||
**Issues:** [#1167](https://github.com/steipete/CodexBar/issues/1167),
|
||||
[#780](https://github.com/steipete/CodexBar/issues/780)
|
||||
|
||||
## Decision summary
|
||||
|
||||
Two opt-in controls should share one presentation policy:
|
||||
|
||||
1. **Also show separately:** while Merge Icons is enabled, selected providers get stable provider status items in
|
||||
addition to the merged item.
|
||||
2. **Merged icon source:** choose `Current selection`, `Highest usage`, or `Frontmost provider app`.
|
||||
|
||||
Provider pins affect status-item layout only. Frontmost-app matching affects the collapsed merged icon only. Neither
|
||||
feature changes the selected menu tab, selected account, enabled providers, or Overview contents.
|
||||
|
||||
Implementation should ship provider pins and privacy-safe native app matching as separate PRs. Terminal-session
|
||||
inference remains a later, separately reviewed opt-in.
|
||||
|
||||
## Current behavior
|
||||
|
||||
With multiple providers and Merge Icons enabled, CodexBar creates one merged status item and removes all provider
|
||||
status items. `primaryProviderForUnifiedIcon()` chooses the merged icon from highest usage, the first Overview
|
||||
provider, the selected menu provider, or the first enabled provider. Menu selection is persisted.
|
||||
|
||||

|
||||
|
||||
The current implementation spreads the binary merged/split assumption across status-item visibility, menu
|
||||
attachment, icon animation, and observation signatures. Adding either request as another independent conditional
|
||||
would let those paths disagree.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let users pin important providers beside the merged icon without losing the unified menu.
|
||||
- Let the collapsed merged icon follow a recognized, enabled frontmost provider app.
|
||||
- Preserve explicit menu, account, and Overview selection.
|
||||
- Keep provider data siloed and avoid new sensitive-data collection.
|
||||
- Keep status-item identities stable across refreshes and focus changes.
|
||||
- Use an event-driven monitor; no polling or idle battery cost.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Filtering providers out of the merged menu or Overview.
|
||||
- A second provider-content subset; Overview selection remains the only content subset.
|
||||
- Switching provider accounts based on focus.
|
||||
- Reading window titles, terminal scrollback, command lines, working directories, environment variables, prompts, or
|
||||
shell history.
|
||||
- Requiring Accessibility permission for the native-app MVP.
|
||||
- Inferring a terminal tab's provider in the first implementation.
|
||||
|
||||
## User model
|
||||
|
||||
### Provider pins
|
||||
|
||||
Preferences → Display → Menu Bar gains **Also show separately** when Merge Icons is enabled and at least two
|
||||
providers are enabled. Its Configure popover lists enabled providers in provider order.
|
||||
|
||||
- A pinned provider gets its own icon and provider menu.
|
||||
- The provider remains in the merged switcher and Overview eligibility.
|
||||
- The merged icon remains visible even when every enabled provider is pinned.
|
||||
- Turning Merge Icons off restores existing all-separate behavior. Pins remain stored but dormant.
|
||||
- Disabling a pinned provider hides its separate icon but preserves the pin for a later re-enable.
|
||||
|
||||
Pins are additive, not subtractive. This avoids a provider silently disappearing from the unified menu and keeps one
|
||||
consistent place for cross-provider comparison.
|
||||
|
||||
### Merged icon source
|
||||
|
||||
Replace the current highest-usage toggle with a picker:
|
||||
|
||||
| Choice | Collapsed merged icon | Menu opens on |
|
||||
| --- | --- | --- |
|
||||
| Current selection | Existing resolver | Existing persisted selection or Overview |
|
||||
| Highest usage | Existing closest-to-limit resolver | Existing persisted selection or Overview |
|
||||
| Frontmost provider app | Recognized enabled provider; otherwise existing resolver | Existing persisted selection or Overview |
|
||||
|
||||
The existing `menuBarShowsHighestUsage = true` preference migrates to `highestUsage`; false migrates to
|
||||
`currentSelection`. No new provider filter is introduced. This avoids overlap with #1781 and preserves the existing
|
||||
Overview provider subset.
|
||||
|
||||
## Presentation policy
|
||||
|
||||
Introduce a pure model consumed by status-item lifecycle, menus, icons, animation, and tests:
|
||||
|
||||
```swift
|
||||
struct StatusItemLayout: Equatable, Sendable {
|
||||
let showsMergedItem: Bool
|
||||
let separateProviders: [UsageProvider]
|
||||
}
|
||||
|
||||
struct UnifiedIconContext: Equatable, Sendable {
|
||||
let source: UnifiedIconSource
|
||||
let focusedProvider: UsageProvider?
|
||||
let isMergedMenuOpen: Bool
|
||||
}
|
||||
```
|
||||
|
||||
`StatusItemLayout.resolve(...)` takes provider order, enabled providers, `mergeIcons`, stored pins, and fallback state.
|
||||
It returns identities only; it never creates AppKit objects.
|
||||
|
||||
| State | Merged item | Separate items |
|
||||
| --- | --- | --- |
|
||||
| Merge off | hidden | all enabled providers, or existing fallback |
|
||||
| Merge on, fewer than two displayable providers | existing single-provider behavior | existing provider/fallback item |
|
||||
| Merge on, two or more displayable providers | visible | enabled pinned providers in provider order |
|
||||
|
||||
`updateVisibility()`, `rebuildProviderStatusItems()`, `attachMenus()`, icon observation signatures, and animation all
|
||||
consume the same resolved layout. No path independently derives merged versus split visibility.
|
||||
|
||||
The merged item's content and animation inputs continue to include all enabled providers. Each separate provider item
|
||||
uses only that provider. Existing render-signature caches prevent unchanged duplicate work.
|
||||
|
||||
## Focus-aware resolution
|
||||
|
||||
`FrontmostProviderMonitor` observes `NSWorkspace.didActivateApplicationNotification` and seeds itself from
|
||||
`NSWorkspace.frontmostApplication` at startup. It publishes an in-memory `FocusMatch` only when a provider-owned
|
||||
bundle-identifier mapping recognizes the application.
|
||||
|
||||
Provider mappings belong to provider descriptors rather than a central switch. A mapping is eligible only when its
|
||||
provider is enabled. Unknown or disabled apps produce no override.
|
||||
|
||||
The frontmost match is an ephemeral input to unified-icon resolution:
|
||||
|
||||
```text
|
||||
merged menu open ───────────────► keep current menu presentation stable
|
||||
frontmost mode + enabled match ─► render matched provider on collapsed merged item
|
||||
otherwise ──────────────────────► run the existing resolver unchanged
|
||||
```
|
||||
|
||||
Focus changes never write `selectedMenuProvider`, `mergedMenuLastSelectedWasOverview`, account selection, or provider
|
||||
configuration. When the menu closes, the latest focus match can affect the next collapsed render. Activation bursts
|
||||
are coalesced on the main actor; no timer runs while the frontmost app is unchanged.
|
||||
|
||||
### Terminal boundary
|
||||
|
||||
`NSWorkspace` can identify Terminal, iTerm, or Warp as the frontmost app, but not which tab or pane is active. Treating
|
||||
the terminal application itself as one provider would be incorrect.
|
||||
|
||||
If terminal inference is later approved, add provider-source adapters behind a second off-by-default preference. Each
|
||||
adapter must return only a provider identity and confidence/source metadata. It must not retain or log raw terminal
|
||||
content. Permission prompts, supported terminal applications, ambiguity behavior, and energy impact need their own
|
||||
design and Tahoe proof before merge.
|
||||
|
||||
## Settings and migration
|
||||
|
||||
- `separateProvidersWhenMerged: [UsageProvider]`: normalized, de-duplicated provider order; unknown values ignored.
|
||||
- `unifiedIconSource: UnifiedIconSource`: `currentSelection`, `highestUsage`, or `frontmostApp`.
|
||||
- Read the legacy `menuBarShowsHighestUsage` value only when the new enum key is absent.
|
||||
- Preserve pins for disabled providers; expose only enabled providers in the Configure popover.
|
||||
- Turning features off does not delete their stored choices.
|
||||
|
||||
## Lifecycle and privacy
|
||||
|
||||
- Start `FrontmostProviderMonitor` only when `frontmostApp` is selected; stop and unregister when another source is
|
||||
selected or the controller shuts down.
|
||||
- Store only the current matched provider and match source in memory.
|
||||
- Do not persist frontmost-app history.
|
||||
- Logs may contain the resolved provider and source category, never titles, arguments, paths, or raw application data.
|
||||
- Native bundle matching uses public process metadata and requires no new permission.
|
||||
|
||||
## Failure and edge cases
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| Pinned provider disabled | Separate item removed; pin retained dormant |
|
||||
| Provider order changes | Separate items follow new provider order with stable identities |
|
||||
| Every enabled provider pinned | Merged item plus every pinned provider remain visible |
|
||||
| Merge Icons disabled | Existing all-separate behavior; pin controls disabled |
|
||||
| One displayable provider remains | Existing single-provider behavior; no duplicate merged/provider pair |
|
||||
| Focused app unrecognized | Existing merged-icon resolver |
|
||||
| Focused provider disabled | Existing merged-icon resolver |
|
||||
| Focus changes while menu open | Menu and icon presentation stay stable until close |
|
||||
| App terminates without another activation | Re-evaluate `frontmostApplication`; fall back if unmatched |
|
||||
| Screen/menu-bar reconstruction | Recreate identities from `StatusItemLayout`; do not discard stored pins |
|
||||
|
||||
## Testing
|
||||
|
||||
### Pure model
|
||||
|
||||
- Table-test `StatusItemLayout` for merge on/off, zero/one/many providers, every pin subset, provider reordering,
|
||||
disabled pins, fallback state, and all-providers-pinned.
|
||||
- Table-test unified-icon precedence for all three sources, menu-open suppression, disabled matches, unknown apps,
|
||||
Overview selection, explicit provider selection, and missing snapshots.
|
||||
- Test migration from `menuBarShowsHighestUsage` and round-trip normalization of provider pins.
|
||||
|
||||
### Controller seams
|
||||
|
||||
- Extend split lifecycle tests to prove stable status-item identities while pins, order, and enabled state change.
|
||||
- Prove merged and pinned menus attach to the correct item and never share provider-only menu state.
|
||||
- Prove animation and icon observation signatures include the resolved layout and focus match.
|
||||
- Inject a fake workspace event source; do not activate real applications in unit tests.
|
||||
- Prove monitor registration, coalescing, and shutdown without AppKit status-bar construction where possible.
|
||||
|
||||
### Tahoe visual and performance proof
|
||||
|
||||
Use a freshly packaged exact-head build in the macOS Tahoe VM:
|
||||
|
||||
1. Verify one merged item plus the selected provider pins.
|
||||
2. Toggle pins, provider enabled state, Merge Icons, and provider order; capture redacted screenshots.
|
||||
3. Switch between two mapped native apps and an unmapped app; verify only the collapsed merged icon changes.
|
||||
4. Open the menu during focus switches; verify the selected tab/account and Overview stay fixed.
|
||||
5. Profile repeated focus changes and merged-menu scrolling. Require no persistent idle timer, no status-item churn, and
|
||||
no regression from the native Overview scroller.
|
||||
|
||||
Run focused model/controller suites, `make check`, and `make test`. Keep all live proof free of account identity, usage
|
||||
values, and unpublished provider/model data.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. **Provider pins PR:** settings, pure layout model, controller adoption, tests, Tahoe screenshots.
|
||||
2. **Native focus PR:** enum migration, provider mappings, event monitor, collapsed-icon precedence, energy proof.
|
||||
3. **Terminal adapters:** only after a separate product/privacy decision.
|
||||
|
||||
Each implementation PR should remain independently reversible. Neither changes provider fetching or account state.
|
||||
|
||||
## Accepted owner decisions
|
||||
|
||||
| Decision | Recommendation | Alternative and cost |
|
||||
| --- | --- | --- |
|
||||
| Are pins additive? | Yes; pinned providers remain in merged content | Removing them fragments Overview/switcher semantics |
|
||||
| Keep merged item when all providers are pinned? | Yes; explicit Merge Icons promise stays stable | Auto-hide makes layout change as the last pin toggles |
|
||||
| Unified-icon setting shape | One three-choice picker | Multiple toggles create conflicting precedence |
|
||||
| Focus effect | Collapsed merged icon only | Mutating selection makes focus fight explicit user state |
|
||||
| First focus scope | Native provider apps only | Terminal inference adds privacy, permission, and ambiguity work |
|
||||
| Terminal follow-up | Separate off-by-default design | Bundling it blocks the privacy-safe MVP |
|
||||
|
||||
## Acceptance
|
||||
|
||||
The six recommendations above are approved. This spec is the bounded implementation contract; it intentionally
|
||||
changes no runtime behavior.
|
||||
Reference in New Issue
Block a user