osdy-pi 1.11.3 → 1.11.5

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 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 inspects Joker/Gentle configuration without activating anything. 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. 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** lists validated profiles and current/default metadata. **View usage: profile** explicitly queries that profile's stored Codex credentials without activating it; opening, navigating or highlighting never queries. The preview shows remaining quota, next resets and checked time (local time); absent data is unknown, not zero. Switch, set default, and clear default require an **in-screen confirmation** with Cancel selected initially; **Esc** cancels confirmation without closing the overlay. Resize if the confirmation cannot fit. 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 subscription quota (used percentage, 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. 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. Account read-only previews are cancelled on 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.
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** → **View usage: profile** → optionally **Switch to profile** (Activate, Cancel-first confirmation). Each profile has its own explicit preview before its separate switch operation; the active profile also has a preview.
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: profile** preview 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.
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
 
@@ -303,7 +303,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
303
303
  | Header and mascot | Independently selectable `osdy-theme`/`neon` headers and `current`/`bts` mascots |
304
304
  | Messages | Native colored user cards without emojis and plain assistant Markdown with a separate assistant `🦝` row |
305
305
  | Input | Responsive auto editor by default, with selectable simple Pi-native or extended framed modes |
306
- | Status | Theme-aware Braille spinner and animated working label, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
306
+ | Status | Six activity labels with compact per-state glyphs and a shared theme-color pulse, responsive footer metrics, dynamic extension statuses, and Codex subscription quota with low-capacity emphasis |
307
307
  | Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
308
308
  | Git | Working-tree summary and a centered, filterable diff panel |
309
309
  | Audio | Optional event sounds on macOS and Windows |
@@ -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 only in `/usage`.
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
- Usage loads once when the session starts and refreshes when the modal opens or `r` is pressed; it does not poll. A refresh clears the previous snapshot before authentication resolves, and shutdown clears state, so quota data cannot cross account or profile boundaries.
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
- > **Privacy:** this active-account `/usage` path resolves OAuth through Pi's `modelRegistry`, rather than directly reading auth files. The separate Control Center **View usage: profile** 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.
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
 
@@ -475,7 +477,20 @@ The default `auto` editor mode preserves the responsive behavior: it uses the fr
475
477
 
476
478
  In auto or extended mode at a non-small width, the framed editor shows the model and thinking level in its title and session usage in its footer. For an account-profile launch, the left title shows the active profile name instead of `Osdy-Pi`. When the native editor is effective (simple mode or any small terminal), the Osdy footer instead shows model, active profile when present, thinking, and usage rows before its path/branch and status rows. It uses the currently active Pi/Osdy theme palette; no separate editor theme selector exists. Usage covers input, output, cache read, cache write when present, cost, and context. If Pi supports autocomplete, the editor uses Pi's native autocomplete rendering while the completion UI is visible.
477
479
 
478
- While work is active, Osdy Pi shows the original Braille spinner above the editor in the selected theme's accent color. A two-color wave travels across the `Working...` and `Running ...` letters: 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 stays plain, as does the accent spinner. The spinner and label update when the theme changes. Osdy Pi hides Pi's built-in working row while enabled to avoid a duplicate indicator.
480
+ While work is active, Osdy Pi centers an activity label and compact animated glyph above the editor:
481
+
482
+ | Activity | Glyph frames | Meaning |
483
+ | --- | --- | --- |
484
+ | `Thinking...` | ◌ ◎ ◉ ● | Waiting on the model without active tools—not guaranteed internal reasoning. |
485
+ | `Exploring...` | ✶ ✷ ✸ ✹ | Recognized reads, searches, and documentation tools. |
486
+ | `Verifying...` | ◇ ◈ ◆ ◈ | Recognized checks. |
487
+ | `Working...` | ✻ ✼ ✽ ❋ | Recognized mutations. |
488
+ | `Delegating...` | ✧ ✦ ✧ | Subagent handoffs. |
489
+ | `Executing...` | ◴ ◷ ◶ ◵ | Unknown or ambiguous tools. |
490
+
491
+ 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.
492
+
493
+ 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.
479
494
 
480
495
  ## Working tree and diff
481
496
 
@@ -576,7 +591,7 @@ An isolated agent directory does **not** make custom audio paths or real provide
576
591
 
577
592
  - 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.
578
593
  - 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.
579
- - Account (manual): with disposable profiles only, check Cancel-first switch/set-default/clear-default confirmations, Esc cancellation, idle switching, and current/default labels. Check explicit **View usage: profile** before optional switching, no query on navigation, and cancellation on exit. Create/rename/remove stay in `/osdy-account`, login in `/login`.
594
+ - 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`.
580
595
  - 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.
581
596
  - 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.
582
597
 
@@ -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 heading = truncateToWidth(` ${title} `, innerWidth, "...", true);
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, "...", true);
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 === undefined) return ["Reset: unavailable"];
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(theme.fg("muted", "No quota windows available"));
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", "Quotas + account"), divider(theme, width)];
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
- export function renderCodexUsageDashboardLines(
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.push(controlHints(theme, width));
555
- return lines;
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(
@@ -1,20 +1,26 @@
1
1
  import { visibleWidth } from "@earendil-works/pi-tui";
2
- import type { HeaderVariant, MascotChoice } from "./types.js";
2
+ import type { HeaderVariant, MascotChoice, WorkingActivity } from "./types.js";
3
3
 
4
4
  export const ANIMATION_ENABLED = true;
5
5
  export const ANIMATION_INTERVAL_MS = 30;
6
6
  export const INTRO_ANIMATION_FRAMES = 28;
7
- export const WORKING_SPINNER_FRAMES = [
8
- "⠋",
9
- "⠙",
10
- "⠹",
11
- "⠸",
12
- "⠼",
13
- "⠴",
14
- "⠦",
15
- "⠧",
16
- "⠇",
17
- "⠏",
7
+ export const WORKING_ACTIVITY_FRAMES: Record<WorkingActivity, readonly string[]> = {
8
+ thinking: ["◌", "◎", "◉", "●"],
9
+ exploring: ["✶", "✷", "✸", "✹"],
10
+ verifying: ["◇", "◈", "◆", "◈"],
11
+ working: ["✻", "✼", "✽", "❋"],
12
+ delegating: ["✧", "✦", "✧"],
13
+ executing: ["◴", "◷", "◶", "◵"],
14
+ };
15
+ export const WORKING_SYMBOL_PULSE: readonly string[] = [
16
+ "mdQuoteBorder",
17
+ "thinkingHigh",
18
+ "accent",
19
+ "borderAccent",
20
+ "accent",
21
+ "thinkingHigh",
22
+ "mdQuoteBorder",
23
+ "mdQuoteBorder",
18
24
  ];
19
25
  export const WORKING_WIDGET_KEY = "osdy-pi-working";
20
26
  export const WORKING_TREE_WIDGET_KEY = "osdy-pi-working-tree";