osdy-pi 1.11.4 → 1.11.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -13
- package/extensions/osdy-pi/codex-usage-refresh.test.ts +96 -0
- package/extensions/osdy-pi/codex-usage-refresh.ts +57 -0
- package/extensions/osdy-pi/codex-usage-ui.test.ts +60 -0
- package/extensions/osdy-pi/codex-usage-ui.ts +64 -9
- package/extensions/osdy-pi/control-center-account.test.ts +35 -18
- package/extensions/osdy-pi/control-center-account.ts +22 -19
- package/extensions/osdy-pi/control-center-agents.test.ts +26 -1
- package/extensions/osdy-pi/control-center-agents.ts +1 -1
- package/extensions/osdy-pi/control-center-usage.test.ts +27 -1
- package/extensions/osdy-pi/control-center-usage.ts +13 -5
- package/extensions/osdy-pi/control-center.test.ts +614 -11
- package/extensions/osdy-pi/control-center.ts +276 -21
- package/extensions/osdy-pi/runtime.test.ts +203 -10
- package/extensions/osdy-pi/runtime.ts +37 -3
- package/extensions/osdy-pi/working-animation.test.ts +8 -0
- package/extensions/osdy-pi/working-child-activity.test.ts +67 -0
- package/extensions/osdy-pi/working-child-activity.ts +67 -0
- package/extensions/osdy-pi/working-controller.test.ts +102 -0
- package/extensions/osdy-pi/working-controller.ts +17 -5
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -59,19 +59,19 @@ Osdy Pi's own extension and themes are bundled—do not install them separately.
|
|
|
59
59
|
|
|
60
60
|
Run **`/osdyConfig`** in interactive Pi to open the Osdy Control Center. The ten categories—**Theme, Header, Mascot, Editor, Git, Sounds, Account, Usage, TODO, and Agents**—provide inline controls or data in the same modal. Existing commands remain available.
|
|
61
61
|
|
|
62
|
-
- Use **↑/↓**, **Home/End**, or **Page Up/Down** to navigate; **Tab** or **←/→** switches between categories and details. **Enter** selects.
|
|
62
|
+
- Use **↑/↓**, **Home/End**, or **Page Up/Down** to navigate; **Tab** or **←/→** switches between categories and details. **Enter** selects. Single blank lines separate the heading, summary, compact options, selected-item help, status, and action hints—not individual options. Gaps yield on short viewports so the selected option and controls remain reachable. Press **?** to expand/collapse the selected help and view notes; resize if expanded help is clipped. Confirmation effects remain complete, and **?** never toggles help during confirmation, path editing, loading, querying, or saving.
|
|
63
63
|
- TODO shows configured selection, current eligibility, and actually loaded SDK registration separately. Opt-in/out requires an idle session and a Cancel-first inline confirmation naming the target, owned Gentle filter effects, and reload. Successful selection closes/disposes the panel and releases its editor hold before reloading once; restart Pi if reload fails. Rejections stay in the panel. No installs or task-history changes occur; legacy `/osdy-pi todo on|off|status` remains available.
|
|
64
|
-
- Agents
|
|
65
|
-
- Theme lists Pi's available themes and marks the current theme. A successful selection applies live and saves globally in the active Pi agent directory; project settings can override it at startup. Failures appear in the modal.
|
|
64
|
+
- Agents shows concise **Joker / Gentle** choices without activating anything. With detail focus, visible lowercase-only **j Joker / g Gentle** letters open the existing confirmation directly; only available, unambiguous rows get shortcuts. **↑/↓ + Enter** remains the fallback. Profile and Agent selections survive category navigation; unavailable choices fall back to the current row. Letters never bypass category focus, confirmation, path input, loading, querying, or saving. Selection is idle-only and Cancel-first confirmed, naming the real target, possible Joker installation, owned agent filters, and preserved unrelated resources. Normal personal Pi only: `pi:dev` and isolated/override sessions are blocked, never redirected to personal settings. Success closes/disposes the panel and releases its editor hold before reloading once; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
|
|
65
|
+
- Theme lists Pi's available themes and marks the current theme. Explicit **Page x/N** pages contain at most **8 themes**, fewer in short viewports or with expanded help. **Page Up/Down** changes pages, **Home/End** reaches the first/last theme, and arrows cross page boundaries. Selection survives category navigation and resizing; resizing adjusts the page around the selected theme. A successful selection applies live and saves globally in the active Pi agent directory; project settings can override it at startup. Failures appear in the modal.
|
|
66
66
|
- Header offers **osdy-theme / neon**; Mascot offers **current / Bts**; Editor offers **auto / extended / simple**, with the current preference marked. Editor also shows the effective mode: small terminals stay simple/native even with extended selected.
|
|
67
67
|
- Preferences save immediately through the same runtime and global Osdy settings store as `/osdy-pi` commands (`<agent-dir>/extensions/osdy-pi/settings.json`). Header/Mascot apply live without closing the panel. Editor replacement waits until all overlays close; if another overlay remains, the next existing responsive watcher tick applies the latest choice. The effective mode reports the actually mounted editor while queued. Opening a category only reads values; while Osdy is disabled, choices save for later without enabling it.
|
|
68
68
|
- A selection shows **Saving** until persistence finishes and **Saved globally** only on success. Failed saves retain the live preference but report **could not be saved**; select again to retry. Application errors report **Not saved**, without claiming the runtime change succeeded. Duplicate selections are ignored while saving.
|
|
69
69
|
- **Git** shows the current branch (or detached HEAD), changed-entry count/clean status, and the current widget position as a read-only session detail. Working-tree enablement applies and saves through the same owner as `/osdy-pi working-tree`; Git inspection never writes Git. `/osdy-pi diff` and position commands are unchanged.
|
|
70
70
|
- **Sounds** shows each event's saved and effective path/source. Configure opens a single-line path editor **inside this overlay**: enter a readable `.mp3`/`.wav` path, then **Enter** to validate and save immediately, or **Esc** to cancel editing. Relative paths resolve against the project directory and save as absolute paths. Clear removes only that event's saved path from the existing v1 audio store (`<agent-dir>/extensions/osdy-pi/audio-notifications.json`). Notifications read this store afresh; refresh failures are reported separately from save failures.
|
|
71
71
|
- Sounds' master enablement is **unavailable**: the existing schema has paths, not an enable switch or presets. Startup `--osdy-pi-sound-<event>` flags override saved paths, so **clearing a saved path does not disable flag-configured playback**. Only **Test effective** explicitly plays a sound; opening categories and configuring paths never play. Tests report unavailable files/platforms or playback failures; playback remains macOS/Windows only. The legacy `/osdy-pi sound setup` wizard is preserved.
|
|
72
|
-
- **Account**
|
|
73
|
-
- **Usage** shows the runtime's cached subscription quota (
|
|
74
|
-
- **Esc** closes without undoing live or saved changes. Account read-only
|
|
72
|
+
- **Account** shows one row per validated profile, marked **Active** and **Default** where applicable. Select a profile, then use its visible context bar: **v View usage**, **s Switch** (inactive profiles), **d Set default**, and **c Clear default** (when a default exists). **v** or **Enter** explicitly queries that profile's stored Codex credentials without activating it; no second action list appears. Opening, navigating or highlighting never queries; stored profiles are never polled. A dedicated quota view reuses `/usage` dashboard content: Session/Weekly cards, additional bucket groups, remaining-capacity bars, resets, and the quotas/account summary with plan and credits when available. Identity belongs to the selected stored profile; unknown provider/model metadata is labeled honestly. Checked and updated times use local time; absent data is unknown, not zero. Refresh/Back controls and query feedback stay pinned while long dashboard content scrolls. **r Refresh** repeats the selected profile query; **s Switch**, when available, confirms activation of the currently viewed inactive profile. Cancelling that confirmation preserves the quota view and scroll position. **b Back** or **Esc** returns to the overview with the profile selected, without closing the overlay. In quota details, **↑/↓** selects Refresh/Back and **Enter** activates it; **Page Up/Down** and **Home/End** scroll long quota data. Switch, set default, and clear default require an **in-screen confirmation** with Cancel selected initially; **Esc** cancels confirmation without closing the overlay. Single blank separators group the confirmation title, complete effects, choices, and instructions; gaps yield on short viewports. Resize if the full confirmation cannot fit—acceptance stays hidden and blocked. Switching waits for Pi to become idle, activates through the existing backend, and refreshes subscription usage. Login remains Pi-owned (`/login`); create/rename/remove remain available in `/osdy-account`. Category navigation never changes profiles or credentials.
|
|
73
|
+
- **Usage** shows the runtime's cached active Codex subscription quota (remaining-capacity bars and percentages, window, reset/fetch times) and the existing local history owner's recorded token/cache totals, cost gaps, and model detail. Select **Range: day/week/month** for the current local calendar period; toggle **Accounts: all/current** for local analytics (unmanaged when no valid current profile exists). Quota always belongs to the active account, not the analytics filter; local token/cost totals never become quota percentages. Select **Refresh quota and local history** explicitly; rendering and filters never fetch. Empty, loading, unavailable, and limited/incomplete coverage are reported honestly. Estimates are not billing or subscription quota. `/usage` and `/osdy-usage` remain unchanged.
|
|
74
|
+
- **Esc** closes without undoing live or saved changes, except when cancelling a confirmation/input or returning from Account quota details. Account read-only queries are cancelled on Back/Esc, category exit or close; late results are ignored even if cancellation is not honored. Other in-flight operations still finish and are not claimed cancelled. Late results cannot replace a closed overlay or another category. On narrow terminals, categories and details share the viewport; use Tab to switch.
|
|
75
75
|
|
|
76
76
|
This modal requires Pi's terminal UI, not RPC, JSON, or print mode. `/osdy-pi` remains the legacy extension command; the external shell command `osdy` (`bin/osdy.mjs`) remains the isolated launcher described below.
|
|
77
77
|
|
|
@@ -216,9 +216,9 @@ For a project-local installation, run `pi remove -l npm:@juicesharp/rpiv-todo` i
|
|
|
216
216
|
|
|
217
217
|
## OpenAI account profiles
|
|
218
218
|
|
|
219
|
-
**Quick path:** `/osdyConfig` → **Account** →
|
|
219
|
+
**Quick path:** `/osdyConfig` → **Account** → select a profile → **v** (or **Enter**) for quota bars. Use **r Refresh** to query again or **b Back** / **Esc** to return with the profile selected. From the overview or quota dashboard, **s Switch** opens the same Cancel-first confirmation for the eligible inactive profile; cancelling from quota preserves the view and scroll position. From the overview, **d** sets the default and **c** clears an existing default, also with Cancel-first confirmation. The active profile also supports explicit stored-credential queries.
|
|
220
220
|
|
|
221
|
-
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain unchanged; Agents configuration remains normal personal Pi only.
|
|
221
|
+
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling. Only **View usage** and the quota view's **Refresh** query; profile navigation does not. It never activates, borrows active credentials, refreshes tokens, writes auth, changes models/session state or overwrites active quota. Missing, malformed, expired or remotely rejected snapshots are unavailable—not zero quota. Even the current profile's stored snapshot may be stale: log in and save through the normal account workflow outside preview. Profile labels and decoded JWT claims do not verify human identity or server validity. Local history remains token/cost estimates, not subscription quota. Legacy `/osdy-account`, `/usage` and `/osdy-usage` remain unchanged; Agents configuration remains normal personal Pi only.
|
|
222
222
|
|
|
223
223
|
Osdy Pi can keep multiple ChatGPT Plus/Pro accounts authenticated and let you choose which one starts Pi. `personal` and `work` are only examples—you can create as many named profiles as you need.
|
|
224
224
|
|
|
@@ -283,7 +283,7 @@ To resume a specific session directly from the terminal:
|
|
|
283
283
|
osdy-pi account use work -- --session /absolute/path/to/session.jsonl
|
|
284
284
|
```
|
|
285
285
|
|
|
286
|
-
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`. Account activation/preservation atomically copies opaque auth files. The separate explicit **View usage
|
|
286
|
+
> **Privacy:** each profile keeps a private `auth.json`; Pi runs against its canonical shared `auth.json`. Account activation/preservation atomically copies opaque auth files. The separate explicit Account **View usage / Refresh** query reads a bounded stored auth snapshot, parses its Codex OAuth fields and unverified JWT claims, and sends the access token/account ID only to the fixed HTTPS Codex usage endpoint. It never refreshes tokens, logs in, switches accounts, writes auth, or queries automatically; it does not print or log credentials. Session history, settings, installed packages, and extension resources are shared, so every profile can access that local state. It keeps Pi's canonical `openai-codex` provider and delegates authentication to Pi's built-in `/login` flow.
|
|
287
287
|
|
|
288
288
|
Account switching is serialized in-process and protected by a private cross-process lock. Osdy Pi accepts only bounded regular auth files, refuses symlink-based auth sources, writes replacements with private permissions, and restores the previous canonical auth and account metadata when activation fails. A separate namespace lock protects concurrent profile creation, rename, and removal. Launchers activate the selected auth before starting Pi while continuing to use the shared agent directory; existing managed-directory links from earlier profile layouts are resolved for compatibility.
|
|
289
289
|
|
|
@@ -427,11 +427,13 @@ Run `/usage` to open the Codex subscription dashboard for the active managed `op
|
|
|
427
427
|
|
|
428
428
|
The main windows are labeled **Session** and **Weekly** (the API/domain remains primary/secondary). Their remaining-capacity bars are full at 100% remaining and empty at 0%; labels use the active theme's bold accent, Session uses the accent fill, Weekly uses `mdLink`, and empty segments are muted. The remaining percentage stays muted above 40%, changes to the theme's warning color at 40% or less, and changes to its error color at 15% or less. The same thresholds apply in the `/usage` dashboard and compact footer/editor quota bars. A double themed frame, section dividers, and spacing separate the display. Detail cards show each duration and relative, local, and UTC reset times. Plan, availability, credits/reset count, and additional buckets appear only when the service supplies them; absent optional values are omitted.
|
|
429
429
|
|
|
430
|
-
At wide widths, the modal pairs the Session and Weekly detail cards. Below 72 content columns, cards and bars stack and account/model data wraps. The native footer and extended editor also show compact Session/Weekly remaining-capacity bars below model and thinking metadata whenever a current Codex snapshot exists. Wide widths combine those bars; narrow widths stack them. Additional buckets appear
|
|
430
|
+
At wide widths, the modal pairs the Session and Weekly detail cards. Below 72 content columns, cards and bars stack and account/model data wraps. The native footer and extended editor also show compact Session/Weekly remaining-capacity bars below model and thinking metadata whenever a current Codex snapshot exists. Wide widths combine those bars; narrow widths stack them. Additional buckets appear in `/usage` and the selected-profile Control Center quota view, not compact editor/footer bars.
|
|
431
431
|
|
|
432
|
-
|
|
432
|
+
Active quota loads when an enabled session starts and refreshes when `/usage` opens or `r` is pressed. It also refreshes while idle on a **60-second** schedule, only after settings are loaded, with Osdy enabled, an interactive terminal UI, an `openai-codex` model selected, and no quota request already in flight. Automatic failures double the retry delay up to **10 minutes**; a successful automatic query restores the 60-second interval. This scheduler never polls stored profiles.
|
|
433
433
|
|
|
434
|
-
|
|
434
|
+
Automatic refresh retains the last snapshot during loading and on error, so an older reading can remain visible until a successful query replaces it. Manual refresh clears the previous snapshot before authentication resolves and supersedes any in-flight automatic request. Account switching refreshes active quota through that same clearing path. Session replacement and shutdown stop the timer, cancel pending requests and clear quota state; late results are ignored.
|
|
435
|
+
|
|
436
|
+
> **Privacy:** this active-account `/usage` path resolves OAuth through Pi's `modelRegistry`, rather than directly reading auth files. The separate Control Center Account **View usage / Refresh** path reads the selected profile's bounded stored credentials snapshot; it never borrows active credentials or refreshes tokens, logs in, switches accounts, writes auth, or queries automatically. Both paths transmit the access token/account ID only to the fixed HTTPS Codex usage endpoint, reject redirects, and bound timeout and response size. Quota previews do not persist or log credentials or display account IDs, tokens, raw response bodies, or endpoint internals.
|
|
435
437
|
|
|
436
438
|
### Local usage analytics (starting in 1.10.0)
|
|
437
439
|
|
|
@@ -486,6 +488,19 @@ While work is active, Osdy Pi centers an activity label and compact animated gly
|
|
|
486
488
|
| `Delegating...` | ✧ ✦ ✧ | Subagent handoffs. |
|
|
487
489
|
| `Executing...` | ◴ ◷ ◶ ◵ | Unknown or ambiguous tools. |
|
|
488
490
|
|
|
491
|
+
**Subagent assignment is not live activity.** While a `subagent_run` or `subagent_continue` parent call is active, the indicator distinguishes the two:
|
|
492
|
+
|
|
493
|
+
| Source | Example | Meaning |
|
|
494
|
+
| --- | --- | --- |
|
|
495
|
+
| Validated public `agent` and optional `label` arguments | `explore · Task: map footer data` | Submitted assignment, not a claimed tool operation. This provider-neutral fallback works with Gentle Agents, which currently emits no live child progress. |
|
|
496
|
+
| Public foreground progress from Joker (`pi-subagents-j0k3r`) | `scout · read` | Observable child activity; takes precedence over assignment copy. |
|
|
497
|
+
|
|
498
|
+
Without a usable label, assignment copy is `Task assigned`; continuations without agent identity use `Subagent`, never an inferred identity. Full tasks, prompts and context are never read or displayed. Model activity uses fixed `Waiting on model` / `Responding` labels, never reasoning or response text.
|
|
499
|
+
|
|
500
|
+
For multiple running children, the indicator uses the first child in snapshot order with a compact additional-child count. Missing, malformed or no-running-child progress restores the assignment, or ordinary parent activity when assignment metadata is unavailable. Labels are bounded, control-safe and clipped to terminal width. Matching tool end, agent/session end and reload cleanup discard per-call state.
|
|
501
|
+
|
|
502
|
+
**Background limitation:** assignments appear only while their parent call is active. There is no persistent background live feed, polling or task cache.
|
|
503
|
+
|
|
489
504
|
When tools overlap, the latest remaining tool owns the display. All glyphs share a brightness pulse through the active theme's `mdQuoteBorder`, `thinkingHigh`, `accent`, and `borderAccent` colors; the animation signals activity, not measured percentage progress. Glyphs and labels use a single 80 ms timer, with no new controls or settings.
|
|
490
505
|
|
|
491
506
|
A two-color wave travels across the label: the current letter uses the active theme's `accent`, the trailing letter uses `warning`, and the rest use `text`. Only the current `accent` letter tries the active theme's bold styling; this is a terminal-dependent visual trial, not a change to font size. The trailing `warning` letter and glyph stay plain. The glyph and label update when the theme changes. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
|
|
@@ -589,7 +604,7 @@ An isolated agent directory does **not** make custom audio paths or real provide
|
|
|
589
604
|
|
|
590
605
|
- Open `/osdyConfig` in regular and fullscreen Pi. Check arrows/Home/End/Page Up/Down, Tab or left/right, Enter, and Esc; shrink and restore the terminal, checking both panes remain reachable.
|
|
591
606
|
- In disposable settings, change a visual preference, wait for **Saved globally**, close and reopen, then reload/restart to check persistence. Esc does not undo changes; distinguish save/application errors from success.
|
|
592
|
-
- Account (manual): with disposable profiles only, check Cancel-first switch/set-default/clear-default confirmations, Esc cancellation, idle switching, and current/default labels. Check
|
|
607
|
+
- Account (manual): with disposable profiles only, check Cancel-first switch/set-default/clear-default confirmations, Esc cancellation, idle switching, and current/default labels. Check one row per profile with **v/s/d/c** context actions and **Enter** usage fallback, without a second action list. From **v** quota details, check eligible **s Switch** opens the same Cancel-first confirmation, with blank separators between the title/effects, choices, and instructions; Cancel/Esc must retain the quota view and scroll position. Check gaps yield on short viewports without hiding safety content or enabling acceptance when the full confirmation cannot fit. Check remaining-capacity bars, **r Refresh**, **b Back/Esc** returning to the selected profile, long-view scrolling, no query on navigation, no stored-profile polling, and cancellation on exit. Create/rename/remove stay in `/osdy-account`, login in `/login`.
|
|
593
608
|
- Usage (manual): check day/week/month and all/current filters, empty/unavailable/limited states, and explicit refresh. Authenticated quota and post-switch account correctness need an intentionally authorized live provider check; local estimates are not billing.
|
|
594
609
|
- Sounds (manual): use disposable readable `.mp3`/`.wav` files to check configure, cancel, clear, and startup-flag precedence. Only choose **Test effective** when playback is intended; actual macOS/Windows playback still needs a live check.
|
|
595
610
|
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import type { CodexUsageRefreshClock, CodexUsageRefreshTimer } from "./codex-usage-refresh.js";
|
|
5
|
+
|
|
6
|
+
registerHooks({
|
|
7
|
+
resolve(specifier, context, nextResolve) {
|
|
8
|
+
if (specifier === "./codex-usage-refresh.js") {
|
|
9
|
+
return { shortCircuit: true, url: new URL("./codex-usage-refresh.ts", context.parentURL).href };
|
|
10
|
+
}
|
|
11
|
+
return nextResolve(specifier, context);
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
const { createCodexUsageRefresh } = await import("./codex-usage-refresh.js");
|
|
15
|
+
|
|
16
|
+
class FakeClock implements CodexUsageRefreshClock {
|
|
17
|
+
now = 0;
|
|
18
|
+
jobs = new Map<CodexUsageRefreshTimer, { at: number; run: () => void }>();
|
|
19
|
+
setTimeout(run: () => void, delay: number): CodexUsageRefreshTimer {
|
|
20
|
+
const handle = { cancel: () => { this.jobs.delete(handle); } };
|
|
21
|
+
this.jobs.set(handle, { at: this.now + delay, run });
|
|
22
|
+
return handle;
|
|
23
|
+
}
|
|
24
|
+
async advance(ms: number): Promise<void> {
|
|
25
|
+
this.now += ms;
|
|
26
|
+
for (const [handle, job] of this.jobs) {
|
|
27
|
+
if (job.at <= this.now) { this.jobs.delete(handle); job.run(); }
|
|
28
|
+
}
|
|
29
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
void test("idle refresh waits 60 seconds and owns one timer and request", async () => {
|
|
34
|
+
const clock = new FakeClock();
|
|
35
|
+
let calls = 0;
|
|
36
|
+
let finish: (value: boolean) => void = () => {};
|
|
37
|
+
const service = createCodexUsageRefresh(() => {
|
|
38
|
+
calls++;
|
|
39
|
+
return new Promise<boolean>(resolve => { finish = resolve; });
|
|
40
|
+
}, clock);
|
|
41
|
+
service.start(); service.start();
|
|
42
|
+
assert.equal(clock.jobs.size, 1);
|
|
43
|
+
await clock.advance(59_999); assert.equal(calls, 0);
|
|
44
|
+
await clock.advance(1); assert.equal(calls, 1);
|
|
45
|
+
await clock.advance(600_000); assert.equal(calls, 1);
|
|
46
|
+
assert.equal(clock.jobs.size, 0);
|
|
47
|
+
finish(true); await clock.advance(0);
|
|
48
|
+
assert.equal(clock.jobs.size, 1);
|
|
49
|
+
await clock.advance(59_999); assert.equal(calls, 1);
|
|
50
|
+
await clock.advance(1); assert.equal(calls, 2);
|
|
51
|
+
service.stop(); finish(true); await clock.advance(0);
|
|
52
|
+
assert.equal(clock.jobs.size, 0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
void test("failures increase delay to a bounded ten minutes and success resets it", async () => {
|
|
56
|
+
const clock = new FakeClock();
|
|
57
|
+
let result: boolean | undefined = false;
|
|
58
|
+
let calls = 0;
|
|
59
|
+
const service = createCodexUsageRefresh(() => { calls++; return Promise.resolve(result); }, clock);
|
|
60
|
+
service.start();
|
|
61
|
+
for (const delay of [60_000, 120_000, 240_000, 480_000, 600_000, 600_000]) {
|
|
62
|
+
const before = calls;
|
|
63
|
+
await clock.advance(delay - 1); assert.equal(calls, before);
|
|
64
|
+
await clock.advance(1); assert.equal(calls, before + 1);
|
|
65
|
+
}
|
|
66
|
+
result = true; await clock.advance(600_000);
|
|
67
|
+
const recovered = calls;
|
|
68
|
+
await clock.advance(60_000); assert.equal(calls, recovered + 1);
|
|
69
|
+
result = undefined; await clock.advance(60_000);
|
|
70
|
+
assert.equal(clock.jobs.size, 1, "skipped refresh still retries at normal cadence");
|
|
71
|
+
service.stop();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
void test("stop and restart isolate both pending timers and pending completions", async () => {
|
|
75
|
+
const clock = new FakeClock();
|
|
76
|
+
const finishes: Array<(value: boolean) => void> = [];
|
|
77
|
+
const service = createCodexUsageRefresh(() => new Promise<boolean>(resolve => finishes.push(resolve)), clock);
|
|
78
|
+
service.start(); service.stop();
|
|
79
|
+
await clock.advance(60_000); assert.equal(finishes.length, 0);
|
|
80
|
+
service.start(); await clock.advance(60_000);
|
|
81
|
+
service.stop(); service.start();
|
|
82
|
+
finishes[0]?.(false); await clock.advance(0);
|
|
83
|
+
assert.equal(clock.jobs.size, 1);
|
|
84
|
+
await clock.advance(60_000); assert.equal(finishes.length, 2);
|
|
85
|
+
service.stop(); finishes[1]?.(true); await clock.advance(0);
|
|
86
|
+
assert.equal(clock.jobs.size, 0);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
void test("unexpected refresh rejection backs off without an unhandled promise", async () => {
|
|
90
|
+
const clock = new FakeClock(); let calls = 0;
|
|
91
|
+
const service = createCodexUsageRefresh(() => { calls++; return Promise.reject(new Error("offline")); }, clock);
|
|
92
|
+
service.start(); await clock.advance(60_000);
|
|
93
|
+
await clock.advance(119_999); assert.equal(calls, 1);
|
|
94
|
+
await clock.advance(1); assert.equal(calls, 2);
|
|
95
|
+
service.stop();
|
|
96
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface CodexUsageRefreshTimer { cancel(): void }
|
|
2
|
+
|
|
3
|
+
export interface CodexUsageRefreshClock {
|
|
4
|
+
setTimeout(callback: () => void, delayMs: number): CodexUsageRefreshTimer;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const clock: CodexUsageRefreshClock = {
|
|
8
|
+
setTimeout: (callback, delayMs) => {
|
|
9
|
+
const timer = setTimeout(callback, delayMs);
|
|
10
|
+
timer.unref();
|
|
11
|
+
return { cancel: () => clearTimeout(timer) };
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const INTERVAL_MS = 60_000;
|
|
16
|
+
const MAX_RETRY_MS = 600_000;
|
|
17
|
+
|
|
18
|
+
/** One session owns one timer; undefined means eligibility skipped the query. */
|
|
19
|
+
export function createCodexUsageRefresh(
|
|
20
|
+
refresh: () => Promise<boolean | undefined>,
|
|
21
|
+
timers: CodexUsageRefreshClock = clock,
|
|
22
|
+
): { start(): void; stop(): void } {
|
|
23
|
+
let running = false;
|
|
24
|
+
let generation = 0;
|
|
25
|
+
let timer: CodexUsageRefreshTimer | undefined;
|
|
26
|
+
let delayMs = INTERVAL_MS;
|
|
27
|
+
|
|
28
|
+
const schedule = (owner: number): void => {
|
|
29
|
+
timer = timers.setTimeout(() => {
|
|
30
|
+
timer = undefined;
|
|
31
|
+
void tick(owner);
|
|
32
|
+
}, delayMs);
|
|
33
|
+
};
|
|
34
|
+
const tick = async (owner: number): Promise<void> => {
|
|
35
|
+
if (!running || owner !== generation) return;
|
|
36
|
+
let result: boolean | undefined;
|
|
37
|
+
try { result = await refresh(); } catch { result = false; }
|
|
38
|
+
if (!running || owner !== generation) return;
|
|
39
|
+
if (result === false) delayMs = Math.min(delayMs * 2, MAX_RETRY_MS);
|
|
40
|
+
else if (result === true) delayMs = INTERVAL_MS;
|
|
41
|
+
schedule(owner);
|
|
42
|
+
};
|
|
43
|
+
return {
|
|
44
|
+
start() {
|
|
45
|
+
if (running) return;
|
|
46
|
+
running = true;
|
|
47
|
+
delayMs = INTERVAL_MS;
|
|
48
|
+
schedule(++generation);
|
|
49
|
+
},
|
|
50
|
+
stop() {
|
|
51
|
+
running = false;
|
|
52
|
+
generation++;
|
|
53
|
+
timer?.cancel();
|
|
54
|
+
timer = undefined;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -50,6 +50,8 @@ const {
|
|
|
50
50
|
formatCodexUsageWindow,
|
|
51
51
|
renderCompactCodexQuotaBars,
|
|
52
52
|
formatRemainingPercent,
|
|
53
|
+
renderControlCenterQuotaLines,
|
|
54
|
+
renderCodexUsageDashboardContentLines,
|
|
53
55
|
renderCodexUsageDashboardLines,
|
|
54
56
|
renderCodexUsagePanelLines,
|
|
55
57
|
renderCodexUsageReadyLines,
|
|
@@ -1136,6 +1138,64 @@ void test("repaints the modal after refresh synchronously enters loading and whe
|
|
|
1136
1138
|
assert.deepEqual(state, { kind: "ready", snapshot: refreshedSnapshot });
|
|
1137
1139
|
});
|
|
1138
1140
|
|
|
1141
|
+
void test("Control Center shared quota bars preserve extremes, unknowns, windows and ANSI width", () => {
|
|
1142
|
+
const theme: SimpleTheme = { fg: (_color, text) => `\x1b[32m${text}\x1b[0m` };
|
|
1143
|
+
const now = 1_900_000_000_000;
|
|
1144
|
+
const snapshot: CodexUsageSnapshot = { fetchedAt: now, planType: undefined, credits: undefined, ordinaryUsageAllowed: undefined,
|
|
1145
|
+
buckets: [
|
|
1146
|
+
{ id: "codex", label: "Codex", primary: { usedPercent: 0, windowMinutes: 30, resetsAt: now / 1000 + 60 },
|
|
1147
|
+
secondary: { usedPercent: 100, windowMinutes: 10080, resetsAt: undefined } },
|
|
1148
|
+
{ id: "extra", label: "Extra\x1b[2J bucket", primary: { usedPercent: NaN, windowMinutes: undefined, resetsAt: NaN }, secondary: undefined },
|
|
1149
|
+
{ id: "missing", label: undefined, primary: undefined, secondary: undefined },
|
|
1150
|
+
] };
|
|
1151
|
+
const text = stripSgr(renderControlCenterQuotaLines(theme, snapshot, 100, now).join("\n"));
|
|
1152
|
+
assert.match(text, /Session 30m 100% left █+/);
|
|
1153
|
+
assert.match(text, /Weekly 7d 0% left ░+/);
|
|
1154
|
+
assert.match(text, /Session quota window unknown/);
|
|
1155
|
+
assert.match(text, /resets in 1m/);
|
|
1156
|
+
assert.ok(text.includes(`Next reset: ${new Date(now + 60000).toLocaleString()}`));
|
|
1157
|
+
assert.match(text, /Quota and next reset: unknown/);
|
|
1158
|
+
assert.doesNotMatch(text, /NaN/);
|
|
1159
|
+
assert.equal(text.includes("\x1b[2J"), false);
|
|
1160
|
+
for (const width of [1, 8, 24, 48, 100]) {
|
|
1161
|
+
const lines = renderControlCenterQuotaLines(theme, snapshot, width, now);
|
|
1162
|
+
assert.ok(lines.every(line => visibleWidth(line) <= width), `width ${width}`);
|
|
1163
|
+
}
|
|
1164
|
+
assert.equal(formatRemainingPercent(Infinity), "unknown");
|
|
1165
|
+
assert.equal(formatRemainingPercent(-10), "100% left");
|
|
1166
|
+
assert.equal(formatRemainingPercent(110), "0% left");
|
|
1167
|
+
assert.match(renderControlCenterQuotaLines(theme, { ...snapshot, buckets: [] }, 100).join(" "), /unknown/);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
void test("Pure dashboard content matches /usage groups while leaving caller controls and identity separate", () => {
|
|
1171
|
+
const theme: SimpleTheme = { fg: (_color, text) => `${String.fromCharCode(27)}[32m${text}${String.fromCharCode(27)}[0m` };
|
|
1172
|
+
const now = 1_900_000_000_000;
|
|
1173
|
+
const snapshot: CodexUsageSnapshot = { fetchedAt: now, planType: "plus", ordinaryUsageAllowed: false,
|
|
1174
|
+
credits: { hasCredits: true, unlimited: false, balance: "0", resetCreditCount: 0 },
|
|
1175
|
+
buckets: [
|
|
1176
|
+
{ id: "codex", label: "Codex", primary: { usedPercent: 0, windowMinutes: 300, resetsAt: now / 1000 + 60 },
|
|
1177
|
+
secondary: { usedPercent: 100, windowMinutes: 10080, resetsAt: 0 } },
|
|
1178
|
+
{ id: "extra", label: "Additional", primary: { usedPercent: NaN, windowMinutes: undefined, resetsAt: NaN }, secondary: undefined },
|
|
1179
|
+
{ id: "missing", label: undefined, primary: undefined, secondary: undefined },
|
|
1180
|
+
] };
|
|
1181
|
+
for (let width = 1; width <= 120; width++) {
|
|
1182
|
+
const presentation = { profile: "personal" };
|
|
1183
|
+
const content = renderCodexUsageDashboardContentLines(theme, snapshot, presentation, now, width);
|
|
1184
|
+
const dashboard = renderCodexUsageDashboardLines(theme, snapshot, presentation, now, width);
|
|
1185
|
+
assert.deepEqual(content, dashboard.slice(0, -1), `shared content at width ${width}`);
|
|
1186
|
+
assert.ok(content.every(line => visibleWidth(line) <= width), `ANSI width ${width}: ${JSON.stringify(content.filter(line => visibleWidth(line) > width).map(stripSgr))}`);
|
|
1187
|
+
assert.doesNotMatch(stripSgr(content.join("\n")), /NaN|Invalid Date|esc\/q close/);
|
|
1188
|
+
}
|
|
1189
|
+
const text = stripSgr(renderCodexUsageDashboardContentLines(theme, snapshot, { profile: "personal" }, now, 120).join("\n"));
|
|
1190
|
+
for (const field of ["Session", "Weekly", "100% left", "0% left", "Additional [extra]", "Quotas + account", "Profile: personal", "Plan: plus", "Availability: not allowed", "Credits: 0", "Credit resets: 0", "resets in 1m", "1970-01-01 00:00 UTC", "Reset: unavailable", "unknown", "No quota windows available"]) assert.ok(text.includes(field), field);
|
|
1191
|
+
assert.doesNotMatch(text, /Profile: work|openai-codex\/|gpt-/);
|
|
1192
|
+
const unlimited = { ...snapshot, credits: { hasCredits: false, unlimited: true, balance: undefined, resetCreditCount: undefined } };
|
|
1193
|
+
assert.match(stripSgr(renderCodexUsageDashboardContentLines(theme, unlimited, {}, now, 100).join("\n")), /Credits: unlimited/);
|
|
1194
|
+
const empty = { ...snapshot, buckets: [], credits: undefined, planType: undefined, ordinaryUsageAllowed: undefined };
|
|
1195
|
+
const absent = stripSgr(renderCodexUsageDashboardContentLines(theme, empty, {}, now, 100).join("\n"));
|
|
1196
|
+
assert.match(absent, /Quotas \+ account/); assert.doesNotMatch(absent, /Credits:|Plan:|Availability:|0% left/);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1139
1199
|
void test("keeps Codex quota out of the editor top border layout", () => {
|
|
1140
1200
|
const title = " Osdy-Pi ";
|
|
1141
1201
|
const modelAndThinking = "openai-codex/gpt-5.6-sol · think medium";
|
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
matchesKey,
|
|
3
3
|
truncateToWidth,
|
|
4
4
|
visibleWidth,
|
|
5
|
+
wrapTextWithAnsi,
|
|
5
6
|
type Component,
|
|
6
7
|
type TUI,
|
|
7
8
|
} from "@earendil-works/pi-tui";
|
|
@@ -60,7 +61,41 @@ function divider(theme: SimpleTheme, width: number): string {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
export function formatRemainingPercent(usedPercent: number): string {
|
|
63
|
-
return `${Math.max(0, Math.round(100 - usedPercent))}% left
|
|
64
|
+
return Number.isFinite(usedPercent) ? `${Math.max(0, Math.min(100, Math.round(100 - usedPercent)))}% left` : "unknown";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shared remaining-capacity presentation for the Control Center's cached snapshots. */
|
|
68
|
+
export function renderControlCenterQuotaWindow(theme: SimpleTheme, window: CodexUsageWindow, width: number,
|
|
69
|
+
name: "Session" | "Weekly" = "Session", now = Date.now()): string[] {
|
|
70
|
+
const maximumWidth = Math.max(1, Math.floor(width));
|
|
71
|
+
const label = `${name} ${formatWindowDuration(window)} ${formatRemainingPercent(window.usedPercent)}`;
|
|
72
|
+
const barWidth = Math.min(28, maximumWidth - visibleWidth(label) - 1);
|
|
73
|
+
const bar = Number.isFinite(window.usedPercent) ? progress(theme, window, Math.max(1, barWidth), quotaTheme(name)) : "";
|
|
74
|
+
const heading = bar && barWidth > 0 ? `${label} ${bar}` : label;
|
|
75
|
+
return [
|
|
76
|
+
...wrapTextWithAnsi(heading, maximumWidth),
|
|
77
|
+
...(bar && barWidth <= 0 ? [progress(theme, window, Math.min(12, maximumWidth), quotaTheme(name))] : []),
|
|
78
|
+
...wrapTextWithAnsi(`Window: ${window.windowMinutes === undefined ? "unknown" : `${window.windowMinutes} minutes`} · Next reset: ${validReset(window.resetsAt) ? new Date(window.resetsAt * 1000).toLocaleString() : "unknown"}`, maximumWidth),
|
|
79
|
+
...(validReset(window.resetsAt) ? wrapTextWithAnsi(formatResetTiming(window.resetsAt, now) ?? "", maximumWidth) : []),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validReset(value: number | undefined): value is number {
|
|
84
|
+
return value !== undefined && Number.isFinite(value) && !Number.isNaN(new Date(value * 1000).getTime());
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function renderControlCenterQuotaLines(theme: SimpleTheme, snapshot: CodexUsageSnapshot, width: number,
|
|
88
|
+
now = Date.now()): string[] {
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
for (const bucket of snapshot.buckets) {
|
|
91
|
+
lines.push(...wrapTextWithAnsi(bucketDisplay(bucket).label, Math.max(1, width)));
|
|
92
|
+
for (const [name, window] of [["Session", bucket.primary], ["Weekly", bucket.secondary]] as const) {
|
|
93
|
+
if (window) lines.push(...renderControlCenterQuotaWindow(theme, window, width, name, now));
|
|
94
|
+
}
|
|
95
|
+
if (!bucket.primary && !bucket.secondary) lines.push("Quota and next reset: unknown");
|
|
96
|
+
}
|
|
97
|
+
return (lines.length ? lines : ["Quota and next reset: unknown (no quota windows returned)."])
|
|
98
|
+
.flatMap(line => wrapTextWithAnsi(line, Math.max(1, width)));
|
|
64
99
|
}
|
|
65
100
|
|
|
66
101
|
function remainingPercentTheme(
|
|
@@ -156,13 +191,14 @@ function borderBox(
|
|
|
156
191
|
titleTheme: "accent" | "mdLink" = "accent",
|
|
157
192
|
): string[] {
|
|
158
193
|
const innerWidth = Math.max(1, width - 2);
|
|
159
|
-
const
|
|
194
|
+
const ellipsis = innerWidth >= 3 ? "..." : "";
|
|
195
|
+
const heading = truncateToWidth(` ${title} `, innerWidth, ellipsis, true);
|
|
160
196
|
const leftWidth = Math.floor(
|
|
161
197
|
Math.max(0, innerWidth - visibleWidth(heading)) / 2,
|
|
162
198
|
);
|
|
163
199
|
const rightWidth = Math.max(0, innerWidth - visibleWidth(heading) - leftWidth);
|
|
164
200
|
const pad = (line: string): string => {
|
|
165
|
-
const text = truncateToWidth(line, innerWidth,
|
|
201
|
+
const text = truncateToWidth(line, innerWidth, ellipsis, true);
|
|
166
202
|
return `${text}${" ".repeat(Math.max(0, innerWidth - visibleWidth(text)))}`;
|
|
167
203
|
};
|
|
168
204
|
return [
|
|
@@ -323,7 +359,7 @@ function formatResetDetails(
|
|
|
323
359
|
resetAt: number | undefined,
|
|
324
360
|
now: number,
|
|
325
361
|
): string[] {
|
|
326
|
-
if (resetAt
|
|
362
|
+
if (!validReset(resetAt)) return ["Reset: unavailable"];
|
|
327
363
|
const date = new Date(resetAt * 1_000);
|
|
328
364
|
return [
|
|
329
365
|
`Reset: ${formatResetRelative(resetAt, now)}`,
|
|
@@ -389,7 +425,7 @@ function renderQuotaBucketSection(
|
|
|
389
425
|
divider(theme, width),
|
|
390
426
|
];
|
|
391
427
|
if (cards.length === 0) {
|
|
392
|
-
lines.push(
|
|
428
|
+
lines.push(...wrapToVisibleWidth("No quota windows available", width).map(line => theme.fg("muted", line)));
|
|
393
429
|
return lines;
|
|
394
430
|
}
|
|
395
431
|
const [primaryCard, secondaryCard] = cards;
|
|
@@ -414,6 +450,8 @@ function combineCards(left: string[], right: string[]): string[] {
|
|
|
414
450
|
|
|
415
451
|
function wrapToVisibleWidth(text: string, width: number): string[] {
|
|
416
452
|
const maximumWidth = Math.max(1, Math.floor(width));
|
|
453
|
+
if (visibleWidth(text) <= maximumWidth) return [text];
|
|
454
|
+
if (text.includes(String.fromCharCode(27))) return wrapTextWithAnsi(text, maximumWidth);
|
|
417
455
|
const lines: string[] = [];
|
|
418
456
|
let line = "";
|
|
419
457
|
for (const character of Array.from(text)) {
|
|
@@ -434,7 +472,7 @@ function renderQuotaFooterLines(
|
|
|
434
472
|
presentation: CodexUsagePresentation,
|
|
435
473
|
width: number,
|
|
436
474
|
): string[] {
|
|
437
|
-
const lines = [theme.fg("accent",
|
|
475
|
+
const lines = [...wrapToVisibleWidth("Quotas + account", width).map(line => theme.fg("accent", line)), divider(theme, width)];
|
|
438
476
|
const quotaLines: string[] = [];
|
|
439
477
|
for (const bucket of snapshot.buckets) {
|
|
440
478
|
const { label } = bucketDisplay(bucket);
|
|
@@ -446,6 +484,10 @@ function renderQuotaFooterLines(
|
|
|
446
484
|
const prefix = `${label} ${name} `;
|
|
447
485
|
const themedLabel = `${theme.fg("muted", `${label} `)}${quotaLabel(theme, name, " ")}`;
|
|
448
486
|
const remaining = formatRemainingPercent(window.usedPercent);
|
|
487
|
+
if (!Number.isFinite(window.usedPercent)) {
|
|
488
|
+
quotaLines.push(...wrapToVisibleWidth(`${themedLabel}unknown`, width));
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
449
491
|
const suffix = ` ${remaining} · ${Math.round(window.usedPercent)}% used`;
|
|
450
492
|
const themedSuffix = `${theme.fg("muted", " ")}${themedRemainingPercent(theme, window.usedPercent)}${theme.fg("muted", ` · ${Math.round(window.usedPercent)}% used`)}`;
|
|
451
493
|
const compactThemedSuffix = `${themedRemainingPercent(theme, window.usedPercent)}${theme.fg("muted", ` · ${Math.round(window.usedPercent)}% used`)}`;
|
|
@@ -539,7 +581,8 @@ function controlHints(theme: SimpleTheme, width: number): string {
|
|
|
539
581
|
);
|
|
540
582
|
}
|
|
541
583
|
|
|
542
|
-
|
|
584
|
+
/** Snapshot-only dashboard content; callers own controls and source-specific metadata. */
|
|
585
|
+
export function renderCodexUsageDashboardContentLines(
|
|
543
586
|
theme: SimpleTheme,
|
|
544
587
|
snapshot: CodexUsageSnapshot,
|
|
545
588
|
presentation: CodexUsagePresentation = {},
|
|
@@ -551,8 +594,20 @@ export function renderCodexUsageDashboardLines(
|
|
|
551
594
|
lines.push(...renderQuotaBucketSection(theme, bucket, now, width));
|
|
552
595
|
lines.push(theme.fg("border", "─".repeat(Math.max(1, width))));
|
|
553
596
|
lines.push(...renderQuotaFooterLines(theme, snapshot, presentation, width));
|
|
554
|
-
lines.
|
|
555
|
-
|
|
597
|
+
return width < 4 ? lines.map(line => truncateToWidth(line, Math.max(1, width), "")) : lines;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export function renderCodexUsageDashboardLines(
|
|
601
|
+
theme: SimpleTheme,
|
|
602
|
+
snapshot: CodexUsageSnapshot,
|
|
603
|
+
presentation: CodexUsagePresentation = {},
|
|
604
|
+
now = Date.now(),
|
|
605
|
+
width = 94,
|
|
606
|
+
): string[] {
|
|
607
|
+
return [
|
|
608
|
+
...renderCodexUsageDashboardContentLines(theme, snapshot, presentation, now, width),
|
|
609
|
+
controlHints(theme, width),
|
|
610
|
+
];
|
|
556
611
|
}
|
|
557
612
|
|
|
558
613
|
export function renderCodexUsagePanelLines(
|
|
@@ -31,15 +31,21 @@ void test("Profile preview is explicit, independently selected and never activat
|
|
|
31
31
|
});
|
|
32
32
|
const initial = await service.read();
|
|
33
33
|
assert.deepEqual(requests, []);
|
|
34
|
-
assert.equal(initial.rows
|
|
35
|
-
assert.
|
|
34
|
+
assert.equal(initial.rows.length, 2);
|
|
35
|
+
assert.equal(initial.rows[0]?.label, "work · Active");
|
|
36
|
+
assert.equal(initial.rows[1]?.action.kind, "account-select");
|
|
37
|
+
assert.equal(initial.rows[1]?.actions?.[0]?.label, "View usage");
|
|
36
38
|
for (const profile of ["work", "personal"]) {
|
|
37
39
|
assert.equal((await service.apply({ kind: "account-usage", profile })).failed, false);
|
|
38
|
-
const row =
|
|
39
|
-
assert.match(row?.details?.join(" ") ?? "", /75%
|
|
40
|
-
assert.match(row?.details?.join(" ") ?? "", /Weekly
|
|
41
|
-
assert.match(row?.details?.join(" ") ?? "", /Extra bucket.*90%
|
|
40
|
+
const row = { details: service.quotaDetails?.(profile) };
|
|
41
|
+
assert.match(row?.details?.join(" ") ?? "", /75% left/);
|
|
42
|
+
assert.match(row?.details?.join(" ") ?? "", /Weekly 7d 20% left.*Next reset: unknown/);
|
|
43
|
+
assert.match(row?.details?.join(" ") ?? "", /Extra bucket.*90% left.*Next reset: unknown/);
|
|
42
44
|
assert.ok(row?.details?.includes(`Profile: ${profile}`));
|
|
45
|
+
assert.equal(service.quotaSnapshot("personal"), profile === "personal" ? service.quotaSnapshot(profile) : undefined);
|
|
46
|
+
assert.equal(service.quotaSnapshot(profile)?.buckets.length, 2);
|
|
47
|
+
assert.ok(row?.details?.some(line => line.startsWith("Window: 300 minutes")));
|
|
48
|
+
assert.ok(row?.details?.some(line => line.startsWith("Window: unknown")));
|
|
43
49
|
assert.ok(row?.details?.includes(`Checked: ${new Date(1000).toLocaleString()} (local time)`));
|
|
44
50
|
assert.ok(row?.details?.some(line => line.endsWith(`Next reset: ${new Date(2000 * 1000).toLocaleString()}`)));
|
|
45
51
|
}
|
|
@@ -61,13 +67,13 @@ void test("Profile preview preserves timestamp units and marks missing or invali
|
|
|
61
67
|
secondary: { usedPercent: 80, windowMinutes: 10080, resetsAt } }] } }),
|
|
62
68
|
});
|
|
63
69
|
await service.apply({ kind: "account-usage", profile: "work" });
|
|
64
|
-
const details =
|
|
70
|
+
const details = service.quotaDetails?.("work") ?? [];
|
|
65
71
|
assert.ok(details.includes(`Checked: ${new Date(checkedAt).toLocaleString()} (local time)`));
|
|
66
72
|
const expectedReset = resetsAt === 1_780_000_000 || resetsAt === 0
|
|
67
73
|
? new Date(resetsAt * 1000).toLocaleString() : "unknown";
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
74
|
+
assert.match(details.join(" "), /Session 5h 75% left/);
|
|
75
|
+
assert.match(details.join(" "), /Weekly 7d 20% left/);
|
|
76
|
+
assert.equal(details.filter(line => line.endsWith(`Next reset: ${expectedReset}`)).length, 2);
|
|
71
77
|
}
|
|
72
78
|
});
|
|
73
79
|
|
|
@@ -81,7 +87,7 @@ void test("Account projects validated metadata, never output or credential field
|
|
|
81
87
|
assert.match(view.summary, /Current: work.*Default: personal/);
|
|
82
88
|
assert.equal(JSON.stringify(view).includes("secret"), false);
|
|
83
89
|
assert.equal(JSON.stringify(view).includes("auth.json"), false);
|
|
84
|
-
assert.equal(view.rows.some(row => row.label === "Switch to personal"), true);
|
|
90
|
+
assert.equal(view.rows.some(row => row.actions?.some(action => action.label === "Switch to personal")), true);
|
|
85
91
|
});
|
|
86
92
|
|
|
87
93
|
void test("Account revalidates membership and uses real switch/default adapters", async () => {
|
|
@@ -144,13 +150,13 @@ void test("Bound Account validates live external active names without rendering
|
|
|
144
150
|
else process.env.OSDY_PI_PROFILE_NAME = value;
|
|
145
151
|
const view = await service.read();
|
|
146
152
|
assert.match(view.summary, /Current: unmanaged/);
|
|
147
|
-
assert.equal(view.rows.some(row => row.label === "Switch to work"), true);
|
|
153
|
+
assert.equal(view.rows.some(row => row.actions?.some(action => action.label === "Switch to work")), true);
|
|
148
154
|
assert.equal(JSON.stringify(view).includes("token-secret"), false);
|
|
149
155
|
assert.equal(JSON.stringify(view).includes("auth.json"), false);
|
|
150
156
|
}
|
|
151
157
|
process.env.OSDY_PI_PROFILE_NAME = "Work";
|
|
152
158
|
assert.match((await service.read()).summary, /Current: Work/);
|
|
153
|
-
assert.equal((await service.read()).rows.some(row => row.label === "Switch to work"), false);
|
|
159
|
+
assert.equal((await service.read()).rows.some(row => row.actions?.some(action => action.label === "Switch to work")), false);
|
|
154
160
|
} finally {
|
|
155
161
|
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
156
162
|
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
@@ -195,15 +201,15 @@ void test("Profile preview cancellation, supersession and stale runtime discard
|
|
|
195
201
|
requests[1]?.finish({ status: "unavailable", profile: "personal", checkedAt: 1000, reason: "remote-usage-unavailable" });
|
|
196
202
|
await second;
|
|
197
203
|
requests[0]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await first;
|
|
198
|
-
assert.match(JSON.stringify(
|
|
199
|
-
assert.doesNotMatch(JSON.stringify(
|
|
204
|
+
assert.match(JSON.stringify(service.quotaDetails?.("personal")), /Remote Codex usage unavailable/);
|
|
205
|
+
assert.doesNotMatch(JSON.stringify(service.quotaDetails?.("personal")), /Stored credentials unavailable \(missing/);
|
|
200
206
|
const third = service.apply({ kind: "account-usage", profile: "work" }); await flush();
|
|
201
207
|
service.cancelUsage?.(); assert.equal(requests[2]?.signal.aborted, true);
|
|
202
208
|
requests[2]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await third;
|
|
203
|
-
assert.doesNotMatch(JSON.stringify(
|
|
209
|
+
assert.doesNotMatch(JSON.stringify(service.quotaDetails?.("work")), /Checked: .*local time/);
|
|
204
210
|
const fourth = service.apply({ kind: "account-usage", profile: "work" }); await flush(); current = false;
|
|
205
211
|
requests[3]?.finish({ status: "unavailable", profile: "work", checkedAt: 1000, reason: "stored-credentials-unavailable" }); await fourth;
|
|
206
|
-
assert.doesNotMatch(JSON.stringify(
|
|
212
|
+
assert.doesNotMatch(JSON.stringify(service.quotaDetails?.("work")), /Checked: .*local time/);
|
|
207
213
|
assert.equal((await service.apply({ kind: "account-switch", profile: "personal" })).failed, true);
|
|
208
214
|
});
|
|
209
215
|
|
|
@@ -220,12 +226,23 @@ void test("Unavailable, cancelled, absent quota and unexpected errors never fabr
|
|
|
220
226
|
},
|
|
221
227
|
});
|
|
222
228
|
await service.apply({ kind: "account-usage", profile: "work" });
|
|
223
|
-
const text = JSON.stringify(
|
|
229
|
+
const text = JSON.stringify(service.quotaDetails?.("work"));
|
|
224
230
|
assert.doesNotMatch(text, /0%|token-secret|private\/auth/);
|
|
225
231
|
assert.match(text, outcome === "empty" ? /unknown/ : /unavailable|cancelled/);
|
|
226
232
|
}
|
|
227
233
|
});
|
|
228
234
|
|
|
235
|
+
void test("A mismatched profile result cannot publish another profile's quota snapshot", async () => {
|
|
236
|
+
const service = createControlCenterAccount({ profiles: () => Promise.resolve(["work", "personal"]), active: () => "work",
|
|
237
|
+
defaultProfile: () => Promise.resolve(undefined), switch: () => Promise.resolve(true), setDefault: () => Promise.resolve(true),
|
|
238
|
+
usage: () => Promise.resolve({ status: "ready", profile: "work", checkedAt: 1000,
|
|
239
|
+
quotaSnapshot: { fetchedAt: 1000, buckets: [], credits: undefined, planType: undefined, ordinaryUsageAllowed: undefined } }),
|
|
240
|
+
});
|
|
241
|
+
assert.equal((await service.apply({ kind: "account-usage", profile: "personal" })).failed, true);
|
|
242
|
+
assert.equal(service.quotaSnapshot("personal"), undefined);
|
|
243
|
+
assert.doesNotMatch(service.quotaDetails("personal").join(" "), /local time/);
|
|
244
|
+
});
|
|
245
|
+
|
|
229
246
|
void test("Bound preview uses injected service independently of active refresh and queued stale activation", async () => {
|
|
230
247
|
let current = true; let release = () => {}; const names: string[] = [];
|
|
231
248
|
const service = bindControlCenterAccount({ isIdle: () => false, waitForIdle: () => new Promise<void>(resolve => { release = resolve; }),
|