osdy-pi 1.11.12 → 1.11.14
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 +18 -9
- package/extensions/osdy-pi/account-profile-selector.test.ts +186 -0
- package/extensions/osdy-pi/account-profile-selector.ts +124 -0
- package/extensions/osdy-pi/account-profiles.test.ts +222 -2
- package/extensions/osdy-pi/account-profiles.ts +40 -13
- package/extensions/osdy-pi/assets/PROVENANCE.md +21 -0
- package/extensions/osdy-pi/assets/notification-default.wav +0 -0
- package/extensions/osdy-pi/audio-default-sound.test.ts +130 -0
- package/extensions/osdy-pi/audio-default-wizard.test.ts +35 -0
- package/extensions/osdy-pi/audio-master-mute.test.ts +103 -0
- package/extensions/osdy-pi/audio-notification-config.ts +15 -2
- package/extensions/osdy-pi/audio-notification-service.ts +6 -3
- package/extensions/osdy-pi/audio-notification-types.ts +2 -1
- package/extensions/osdy-pi/audio-sound-settings.ts +3 -1
- package/extensions/osdy-pi/control-center-git.test.ts +2 -0
- package/extensions/osdy-pi/control-center-sounds.test.ts +25 -8
- package/extensions/osdy-pi/control-center-sounds.ts +25 -16
- package/extensions/osdy-pi/control-center.test.ts +23 -0
- package/extensions/osdy-pi/control-center.ts +2 -1
- package/extensions/osdy-pi/runtime.ts +5 -1
- package/extensions/osdy-pi/sound-setup-wizard.test.ts +33 -0
- package/extensions/osdy-pi/sound-setup-wizard.ts +9 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
6
6
|
|
|
7
7
|
## Release highlights
|
|
8
8
|
|
|
9
|
+
**Audio notifications share a bundled 180 ms default sound on macOS and Windows**, with a persistent master mute in `/osdyConfig` → **Sounds**. Custom paths take precedence: startup flag → saved global path → bundled default. Muting preserves those paths and visual alerts; explicit **Test effective** still plays while muted. See [Audio notifications](#audio-notifications).
|
|
10
|
+
|
|
9
11
|
**The Control Center consolidates Osdy controls in `/osdyConfig`**: ten categories cover appearance, Git, sounds, accounts, usage, and TODO/Agents provider settings. Account quota previews query a selected stored Codex profile without switching it. The header shows the installed Osdy package version when it fits; constrained layouts show only the centered mascot. See [Control Center in Pi](#control-center-in-pi) for confirmation and persistence boundaries.
|
|
10
12
|
|
|
11
13
|
**Codex banked resets show counts and per-reset local expiry** in `/usage` and the Control Center. Press **u** in `/usage` or select **Usage → Use or check banked reset** for Cancel-first, active-account use or pending-attempt checks; stored-profile previews stay read-only. See [Use or check a banked reset](#use-or-check-a-banked-reset).
|
|
@@ -15,6 +17,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
15
17
|
| Area | What ships |
|
|
16
18
|
| --- | --- |
|
|
17
19
|
| Control Center | **Ctrl+Option+O** or `/osdyConfig` opens ten categories, including confirmed TODO/Agents provider selection and explicit per-profile Codex quota previews. |
|
|
20
|
+
| Audio | Shared bundled default, per-event custom paths, and persistent automatic-audio mute; [explicit tests remain available](#audio-notifications). |
|
|
18
21
|
| Local analytics (1.10.0) | `/osdy-usage` opens Summary, Profiles, Models, and History pages for recorded tokens and estimated cost—not billing or subscription quota. |
|
|
19
22
|
| Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
|
|
20
23
|
| Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
|
|
@@ -85,7 +88,7 @@ Positive counts also trigger a best-effort read-only reset-details GET within th
|
|
|
85
88
|
- 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.
|
|
86
89
|
- **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.
|
|
87
90
|
- **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.
|
|
88
|
-
- Sounds
|
|
91
|
+
- **Sounds → Mute automatic audio / Enable automatic audio** is a persistent master switch, enabled by default (including existing v1 files). Muting suppresses all automatic event sounds, even startup-flag paths, without losing saved paths or changing visual notifications. It applies to the next notification without reload; already-started playback is not stopped. **Test effective** still plays while muted; opening categories and configuring paths never play. Startup `--osdy-pi-sound-<event>` flags override saved paths, so **clearing a saved path does not disable flag-configured playback**. Tests report unavailable files/platforms or playback failures; playback remains macOS/Windows only. Configure, Clear, and the legacy `/osdy-pi sound setup` wizard preserve the master state.
|
|
89
92
|
- **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 through `/osdyConfig` waits for Pi to become idle; Ctrl+Option+O refuses busy account changes. Successful switching 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.
|
|
90
93
|
- **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` remains the subscription dashboard; `/osdy-usage` remains local analytics.
|
|
91
94
|
- **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.
|
|
@@ -235,7 +238,7 @@ For a project-local installation, run `pi remove -l npm:@juicesharp/rpiv-todo` i
|
|
|
235
238
|
|
|
236
239
|
**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.
|
|
237
240
|
|
|
238
|
-
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling.
|
|
241
|
+
Preview is **Codex only**, a live query using the selected profile's **stored credentials snapshot**, with no automatic refresh or stored-profile polling. In the Control Center, only **View usage** and the quota view's **Refresh** query; profile navigation does not. `/osdy-account` → **Switch** queries all saved profiles before opening the selector, with up to four previews in parallel. 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 available; Agents configuration remains normal personal Pi only.
|
|
239
242
|
|
|
240
243
|
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.
|
|
241
244
|
|
|
@@ -245,13 +248,17 @@ Osdy Pi can keep multiple ChatGPT Plus/Pro accounts authenticated and let you ch
|
|
|
245
248
|
|
|
246
249
|
| Action | Behavior |
|
|
247
250
|
| --- | --- |
|
|
248
|
-
| **Switch** |
|
|
251
|
+
| **Switch** | Lists profile names and active/default markers only. Use arrows to highlight, **v** for `/usage`-style quota details, and **Enter from the list only** to switch the exact profile after idle waiting. |
|
|
249
252
|
| **Add** | Creates an isolated profile without restarting or changing the default. Select it with **Switch**, then run `/login`. |
|
|
250
253
|
| **Default** | Shows, changes, or clears the profile used by future plain installed `pi` and `npm run pi:dev` launches. It does not switch the current process. |
|
|
251
254
|
| **Rename** | Renames an inactive profile. The default follows the new name when applicable. |
|
|
252
255
|
| **Remove** | Permanently deletes an inactive profile after exact-name confirmation. Removing the default requires a replacement. |
|
|
253
256
|
| **Account info** | Shows the available profiles and marks the active and default profiles without reading credentials. |
|
|
254
257
|
|
|
258
|
+
In **Switch**, press **v** on the highlighted profile to explore its saved preview using the same dashboard as `/usage`, without selecting or activating it. Scroll details with **↑/↓**, **Page Up/Down**, or **Home/End**. **Esc** or **b** returns to the same highlighted profile; **Enter does nothing in details**. **Esc from the list** cancels selection.
|
|
259
|
+
|
|
260
|
+
Switch previews are read-only and never activate accounts, refresh tokens, use resets or persist auth. Quota and **usage unavailable** messaging appear only in **v** details, never in the Switch list. Failed, expired or unsupported profiles remain selectable, including after viewing unavailable details. Opening details reuses the selector's snapshot without another request; reopening **Switch** queries again (up to four in parallel), never polls saved profiles. Reset timings include relative time and UTC timestamps when supplied by Codex. The detailed selector requires Pi's terminal UI.
|
|
261
|
+
|
|
255
262
|
Pi does not expose a supported API for extensions to invoke its OAuth login dialog. After switching to a newly created profile, run Pi's native `/login` and choose **ChatGPT Plus/Pro (Codex)**. This is the only step that remains a separate Pi command; it does not require leaving Pi or opening another terminal.
|
|
256
263
|
|
|
257
264
|
Terminal commands remain available as recovery and automation alternatives:
|
|
@@ -323,7 +330,7 @@ Before rename or removal, close this Pi process when it uses the target and **ma
|
|
|
323
330
|
| 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 |
|
|
324
331
|
| Local analytics | Four keyboard-selectable pages with shared filters, token charts, estimated cost, and private metadata-only history from new assistant turns |
|
|
325
332
|
| Git | Working-tree summary and a centered, filterable diff panel |
|
|
326
|
-
| Audio |
|
|
333
|
+
| Audio | Default-on event sounds on macOS and Windows, with master mute and custom paths |
|
|
327
334
|
|
|
328
335
|
<img width="1280" height="433" alt="Osdy Pi header and editor" src="https://github.com/user-attachments/assets/20c7624d-9ad8-4494-97fb-6b6d81aaf328" />
|
|
329
336
|
|
|
@@ -554,7 +561,7 @@ Use `working-tree position top` or `bottom` to place the summary above or below
|
|
|
554
561
|
|
|
555
562
|
## Audio notifications
|
|
556
563
|
|
|
557
|
-
Osdy Pi
|
|
564
|
+
Osdy Pi plays one shared, short, subtle original bundled WAV by default for every unconfigured audio event on macOS and Windows. Automatic audio is enabled by default; mute it in **Sounds** to turn it off without changing visual alerts. Custom readable `.mp3` or `.wav` files can replace the default per event. Other platforms safely skip playback. macOS playback requires the system `afplay` command; Windows playback requires `powershell.exe` and the Windows Media Player COM component (`WMPlayer.OCX`).
|
|
558
565
|
|
|
559
566
|
| Event | Current meaning |
|
|
560
567
|
| --- | --- |
|
|
@@ -563,7 +570,9 @@ Osdy Pi can play readable `.mp3` or `.wav` files on macOS and Windows. Other pla
|
|
|
563
570
|
| `permission` | Hook is available but dormant until an explicit Pi approval integration uses it. |
|
|
564
571
|
| `question` | Hook is available but dormant until an explicit Pi question integration uses it. |
|
|
565
572
|
|
|
566
|
-
|
|
573
|
+
To mute or re-enable automatic audio, open `/osdyConfig` → **Sounds** and select **Mute automatic audio / Enable automatic audio**. Saved paths stay intact; **Test effective** remains available while muted.
|
|
574
|
+
|
|
575
|
+
Run the guided wizard to configure paths (without changing the master switch):
|
|
567
576
|
|
|
568
577
|
```text
|
|
569
578
|
/osdy-pi sound setup
|
|
@@ -581,7 +590,7 @@ pi \
|
|
|
581
590
|
--osdy-pi-sound-question /absolute/path/question.wav
|
|
582
591
|
```
|
|
583
592
|
|
|
584
|
-
|
|
593
|
+
Path precedence is **startup flag → saved global path → bundled default**. Clearing a saved path (or choosing **Use bundled default** in the wizard) restores the default unless a startup flag overrides. The bundled asset resolves relative to the installed module, not the project directory or home. The master mute suppresses automatic playback from every source; explicit **Test effective** can still play the default while muted. Empty flags do not override saved settings; relative startup paths resolve from the current working directory, while the wizard saves normalized absolute paths. Invalid configured paths (missing, unreadable, or unsupported) are skipped safely **without falling back** to the default.
|
|
585
594
|
|
|
586
595
|
## Local install and development
|
|
587
596
|
|
|
@@ -638,9 +647,9 @@ An isolated agent directory does **not** make custom audio paths or real provide
|
|
|
638
647
|
- Account (manual): with disposable profiles only, check Cancel-first switch/set-default/clear-default confirmations, Esc cancellation, idle switching, and current/default labels. Verify Ctrl+Option+O refuses all three changes while busy, succeeds when idle, and `/osdyConfig` still waits for idle. 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`.
|
|
639
648
|
- Subscription quota (manual): compare **Ctrl+Option+U** and `/usage` (Ctrl+Alt+U on other platforms), including **r Refresh** and Esc/q closing. Verify actual terminal chord delivery; use `/usage` if intercepted or unsupported.
|
|
640
649
|
- 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.
|
|
641
|
-
- 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;
|
|
650
|
+
- 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. The user reported successful live playback of the bundled default; exhaustive macOS/Windows coverage still needs a live check.
|
|
642
651
|
|
|
643
|
-
The user confirmed physical Ctrl+Option+O and Ctrl+Option+U delivery on one Mac terminal; other terminals may intercept or not support these chords.
|
|
652
|
+
The user confirmed physical Ctrl+Option+O and Ctrl+Option+U delivery on one Mac terminal; other terminals may intercept or not support these chords. The user also reported that the bundled default audio works correctly in their live session. Other live TUI appearance, real account switching/authenticated quota, and exhaustive audio/platform coverage remain unverified; this checklist is not a recorded full pass.
|
|
644
653
|
|
|
645
654
|
Install a local checkout into Pi with:
|
|
646
655
|
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { registerHooks } from "node:module";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { InteractiveMode, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { TuiAltScreen, visibleWidth, type Component, type OverlayHandle } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { AccountProfileChoice } from "./account-profile-selector.js";
|
|
9
|
+
|
|
10
|
+
registerHooks({ resolve(specifier, context, next) {
|
|
11
|
+
if (specifier.startsWith(".") && specifier.endsWith(".js") && context.parentURL?.endsWith(".ts")) {
|
|
12
|
+
const url = new URL(`${specifier.slice(0, -3)}.ts`, context.parentURL);
|
|
13
|
+
if (existsSync(fileURLToPath(url))) return { shortCircuit: true, url: url.href };
|
|
14
|
+
}
|
|
15
|
+
return next(specifier, context);
|
|
16
|
+
} });
|
|
17
|
+
const { AccountProfileSelector, selectAccountProfile } = await import("./account-profile-selector.js");
|
|
18
|
+
const { renderCodexUsageDashboardContentLines } = await import("./codex-usage-ui.js");
|
|
19
|
+
const theme = { fg: (_color: string, text: string) => text };
|
|
20
|
+
const choice: AccountProfileChoice = { profile: "Work", label: "Work · 5h 75% left", usage: {
|
|
21
|
+
status: "ready", profile: "Work", checkedAt: 0, quotaSnapshot: {
|
|
22
|
+
fetchedAt: 0, planType: "plus", credits: undefined, ordinaryUsageAllowed: true,
|
|
23
|
+
bankedResetCount: 2, bankedResetDetails: [{ id: "one", expiresAt: 86400000 }, { id: "two", expiresAt: undefined }],
|
|
24
|
+
buckets: [{ id: "codex", label: undefined,
|
|
25
|
+
primary: { usedPercent: 25, windowMinutes: 300, resetsAt: 3600 },
|
|
26
|
+
secondary: { usedPercent: 60, windowMinutes: 10080, resetsAt: 86400 } },
|
|
27
|
+
{ id: "extra", label: "Extra quota", primary: { usedPercent: 5, windowMinutes: 60, resetsAt: 100 }, secondary: undefined }],
|
|
28
|
+
},
|
|
29
|
+
} };
|
|
30
|
+
|
|
31
|
+
// Exercise the installed SDK's actual custom lifecycle and fullscreen input router,
|
|
32
|
+
// without a real terminal, session, auth, or network. Only its host editor is stubbed.
|
|
33
|
+
async function mountedSelector(choices: readonly AccountProfileChoice[]) {
|
|
34
|
+
let input: (data: string) => void = () => {};
|
|
35
|
+
let resize: () => void = () => {};
|
|
36
|
+
const terminal = {
|
|
37
|
+
columns: 80, rows: 8, kittyProtocolActive: false,
|
|
38
|
+
start: (onInput: (data: string) => void, onResize: () => void) => { input = onInput; resize = onResize; },
|
|
39
|
+
stop() {}, async drainInput() {}, write() {}, moveBy() {}, hideCursor() {},
|
|
40
|
+
showCursor() {}, clearLine() {}, clearFromCursor() {}, clearScreen() {},
|
|
41
|
+
setTitle() {}, setProgress() {},
|
|
42
|
+
};
|
|
43
|
+
const tui = new TuiAltScreen(terminal);
|
|
44
|
+
let component: Component | undefined;
|
|
45
|
+
let handle: OverlayHandle | undefined;
|
|
46
|
+
let options: Parameters<ExtensionContext["ui"]["custom"]>[1];
|
|
47
|
+
const host = {
|
|
48
|
+
ui: tui, editor: { getText: () => "preserved", setText() {} },
|
|
49
|
+
editorContainer: { clear() {}, addChild(value: Component) { component = value; } },
|
|
50
|
+
keybindings: {}, disposeActiveSelector() {},
|
|
51
|
+
};
|
|
52
|
+
// This private SDK method is the implementation behind ctx.ui.custom.
|
|
53
|
+
const custom = (InteractiveMode.prototype as unknown as {
|
|
54
|
+
showExtensionCustom: ExtensionContext["ui"]["custom"];
|
|
55
|
+
}).showExtensionCustom;
|
|
56
|
+
const ui = { custom: ((factory, opts) => {
|
|
57
|
+
options = opts;
|
|
58
|
+
return custom.call(host, (sdkTui, sdkTheme, keys, done) => {
|
|
59
|
+
// Inject a deterministic theme instead of initializing user theme discovery.
|
|
60
|
+
const result = factory(sdkTui, theme as typeof sdkTheme, keys, done);
|
|
61
|
+
if (!(result instanceof Promise)) component = result;
|
|
62
|
+
return result;
|
|
63
|
+
}, { ...opts, onHandle: value => { handle = value; opts?.onHandle?.(value); } });
|
|
64
|
+
}) as ExtensionContext["ui"]["custom"] };
|
|
65
|
+
const result = selectAccountProfile(ui, choices);
|
|
66
|
+
await Promise.resolve();
|
|
67
|
+
tui.start();
|
|
68
|
+
tui.renderNow();
|
|
69
|
+
assert(component);
|
|
70
|
+
return { tui, terminal, result, component, options,
|
|
71
|
+
bounds: () => handle?.getBounds(),
|
|
72
|
+
screen: () => (tui as unknown as { previousScreen: string[] }).previousScreen.join("\n"),
|
|
73
|
+
input: (key: string) => { input(key); tui.renderNow(); },
|
|
74
|
+
resize: (columns: number, rows: number) => { terminal.columns = columns; terminal.rows = rows; resize(); tui.renderNow(); },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
void test("custom selector mounts a capturing fullscreen overlay and receives viewport navigation", async () => {
|
|
79
|
+
const choices = Array.from({ length: 30 }, (_value, index) => ({ ...choice, profile: `Profile-${index}`, label: `Profile-${index}`, usage: undefined }));
|
|
80
|
+
const mounted = await mountedSelector(choices);
|
|
81
|
+
try {
|
|
82
|
+
assert.equal(mounted.options?.overlay, true);
|
|
83
|
+
assert(mounted.tui.hasOverlay());
|
|
84
|
+
mounted.input("\u001b[F");
|
|
85
|
+
assert.match(mounted.screen(), /> Profile-29/);
|
|
86
|
+
mounted.input("\u001b[H");
|
|
87
|
+
assert.match(mounted.screen(), /> Profile-0/);
|
|
88
|
+
mounted.input("\u001b[6~");
|
|
89
|
+
assert.match(mounted.screen(), /> Profile-6/);
|
|
90
|
+
mounted.input("\u001b[5~");
|
|
91
|
+
assert.match(mounted.screen(), /> Profile-0/);
|
|
92
|
+
mounted.input("v");
|
|
93
|
+
mounted.input("\r");
|
|
94
|
+
assert(mounted.tui.hasOverlay(), "Enter in details must not complete");
|
|
95
|
+
mounted.input("b");
|
|
96
|
+
mounted.input("\r");
|
|
97
|
+
assert.equal(await mounted.result, "Profile-0");
|
|
98
|
+
assert.equal(mounted.tui.hasOverlay(), false);
|
|
99
|
+
} finally { mounted.tui.stop(); }
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
void test("SDK overlay and component share a responsive viewport at short heights and resize", async () => {
|
|
103
|
+
const mounted = await mountedSelector([choice]);
|
|
104
|
+
try {
|
|
105
|
+
assert.equal(mounted.options?.overlay, true);
|
|
106
|
+
const options = typeof mounted.options?.overlayOptions === "function" ? mounted.options.overlayOptions() : mounted.options?.overlayOptions;
|
|
107
|
+
assert.deepEqual(options, { width: "100%", maxHeight: "100%", margin: 0, nonCapturing: false });
|
|
108
|
+
mounted.input("v");
|
|
109
|
+
for (const [width, height] of [[80, 8], [12, 4], [1, 1], [32, 3], [100, 24], [80, 8]] as const) {
|
|
110
|
+
mounted.resize(width, height);
|
|
111
|
+
mounted.input("\u001b[F");
|
|
112
|
+
const lines = mounted.component.render(width);
|
|
113
|
+
assert(lines.length <= height);
|
|
114
|
+
assert(lines.every(line => visibleWidth(line) <= width));
|
|
115
|
+
assert.deepEqual(mounted.bounds(), { row: 0, col: 0, width, height: lines.length });
|
|
116
|
+
if (height >= 3) {
|
|
117
|
+
assert.equal(lines.length, height, "use exactly the SDK viewport, without invisible content");
|
|
118
|
+
assert.match(lines.at(-1)!, /esc\/b/);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
assert.match(mounted.screen(), /Banked resets: 2/);
|
|
122
|
+
mounted.input("\u001b[H");
|
|
123
|
+
assert.match(mounted.screen(), /Checked:/);
|
|
124
|
+
mounted.input("\u001b");
|
|
125
|
+
mounted.input("\u001b");
|
|
126
|
+
assert.equal(await mounted.result, undefined);
|
|
127
|
+
} finally { mounted.tui.stop(); }
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
void test("details reuse the complete dashboard without active-model attribution or action controls", () => {
|
|
131
|
+
const results: (string | undefined)[] = [];
|
|
132
|
+
const panel = new AccountProfileSelector([choice], theme, () => 500, () => {}, value => results.push(value));
|
|
133
|
+
panel.handleInput("v");
|
|
134
|
+
assert(choice.usage?.status === "ready");
|
|
135
|
+
const dashboard = renderCodexUsageDashboardContentLines(theme, choice.usage.quotaSnapshot,
|
|
136
|
+
{ profile: "Work", provider: "openai-codex" }, 0, 100);
|
|
137
|
+
assert.deepEqual(panel.render(100).slice(3, -1), dashboard);
|
|
138
|
+
assert.match(dashboard.join("\n"), /Banked resets: 2/);
|
|
139
|
+
assert.match(dashboard.join("\n"), /Extra quota/);
|
|
140
|
+
for (const key of ["\r", "r", "u", "s"]) panel.handleInput(key);
|
|
141
|
+
assert.deepEqual(results, []);
|
|
142
|
+
panel.handleInput("\u001b");
|
|
143
|
+
panel.handleInput("\r");
|
|
144
|
+
panel.handleInput("\r");
|
|
145
|
+
assert.deepEqual(results, ["Work"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
void test("details scrolling and resizing bound every line and reach the final dashboard fields", () => {
|
|
149
|
+
let height = 8;
|
|
150
|
+
let renders = 0;
|
|
151
|
+
const panel = new AccountProfileSelector([choice], theme, () => height, () => { renders++; }, () => assert.fail("details cannot finish"));
|
|
152
|
+
panel.handleInput("v");
|
|
153
|
+
for (const width of [1, 3, 12, 32, 100]) {
|
|
154
|
+
for (const key of ["\u001b[H", "\u001b[B", "\u001b[6~", "\u001b[5~", "\u001b[F"]) {
|
|
155
|
+
panel.render(width);
|
|
156
|
+
panel.handleInput(key);
|
|
157
|
+
const lines = panel.render(width);
|
|
158
|
+
assert(lines.length <= height);
|
|
159
|
+
assert(lines.every(line => visibleWidth(line) <= width));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
assert.match(panel.render(100).join("\n"), /Banked resets: 2/);
|
|
163
|
+
height = 4;
|
|
164
|
+
assert(panel.render(12).length <= height);
|
|
165
|
+
height = 500;
|
|
166
|
+
const expanded = panel.render(100).join("\n");
|
|
167
|
+
assert.match(expanded, /Checked:/);
|
|
168
|
+
assert.match(expanded, /Extra quota/);
|
|
169
|
+
assert(renders > 0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
void test("long lists keep highlight visible, back preserves identity, and cancellation returns undefined", () => {
|
|
173
|
+
const choices = Array.from({ length: 30 }, (_value, index) => ({ profile: `Profile-${index}`, label: `Profile-${index}`, usage: undefined }));
|
|
174
|
+
const results: (string | undefined)[] = [];
|
|
175
|
+
const panel = new AccountProfileSelector(choices, theme, () => 5, () => {}, value => results.push(value));
|
|
176
|
+
panel.handleInput("\u001b[F");
|
|
177
|
+
assert.match(panel.render(80).join("\n"), /> Profile-29/);
|
|
178
|
+
panel.handleInput("v");
|
|
179
|
+
assert.match(panel.render(80).join("\n"), /usage unavailable/i);
|
|
180
|
+
panel.handleInput("\u001b[B"); // Scroll, never change the profile in details.
|
|
181
|
+
panel.handleInput("\u001b");
|
|
182
|
+
assert.match(panel.render(80).join("\n"), /> Profile-29/);
|
|
183
|
+
panel.handleInput("\u001b");
|
|
184
|
+
panel.handleInput("\r");
|
|
185
|
+
assert.deepEqual(results, [undefined]);
|
|
186
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
3
|
+
import { renderCodexUsageDashboardContentLines } from "./codex-usage-ui.js";
|
|
4
|
+
import type { ProfileCodexUsageResult } from "./profile-codex-usage.js";
|
|
5
|
+
import type { SimpleTheme } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export interface AccountProfileChoice {
|
|
8
|
+
profile: string;
|
|
9
|
+
label: string;
|
|
10
|
+
usage: ProfileCodexUsageResult | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Snapshot-only explorer: completion is possible only from the profile list. */
|
|
14
|
+
export class AccountProfileSelector implements Component {
|
|
15
|
+
private index = 0;
|
|
16
|
+
private details = false;
|
|
17
|
+
private scroll = 0;
|
|
18
|
+
private width = 80;
|
|
19
|
+
private closed = false;
|
|
20
|
+
|
|
21
|
+
private readonly choices: readonly AccountProfileChoice[];
|
|
22
|
+
private readonly theme: SimpleTheme;
|
|
23
|
+
private readonly height: () => number;
|
|
24
|
+
private readonly requestRender: () => void;
|
|
25
|
+
private readonly done: (profile: string | undefined) => void;
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
choices: readonly AccountProfileChoice[],
|
|
29
|
+
theme: SimpleTheme,
|
|
30
|
+
height: () => number,
|
|
31
|
+
requestRender: () => void,
|
|
32
|
+
done: (profile: string | undefined) => void,
|
|
33
|
+
) {
|
|
34
|
+
this.choices = choices;
|
|
35
|
+
this.theme = theme;
|
|
36
|
+
this.height = height;
|
|
37
|
+
this.requestRender = requestRender;
|
|
38
|
+
this.done = done;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private capacity(): number { return Math.max(1, this.height() - 2); }
|
|
42
|
+
|
|
43
|
+
private detailLines(width: number): string[] {
|
|
44
|
+
const choice = this.choices[this.index];
|
|
45
|
+
const usage = choice?.usage;
|
|
46
|
+
if (!choice || usage?.status !== "ready" || usage.profile.toLowerCase() !== choice.profile.toLowerCase()) {
|
|
47
|
+
return [this.theme.fg("muted", "Profile usage unavailable. You can still switch from the list.")];
|
|
48
|
+
}
|
|
49
|
+
return [
|
|
50
|
+
this.theme.fg("muted", `Checked: ${new Date(usage.checkedAt).toLocaleString()} (local time)`),
|
|
51
|
+
this.theme.fg("muted", `Updated: ${new Date(usage.quotaSnapshot.fetchedAt).toLocaleString()} (local time)`),
|
|
52
|
+
...renderCodexUsageDashboardContentLines(this.theme, usage.quotaSnapshot,
|
|
53
|
+
{ profile: choice.profile, provider: "openai-codex" }, usage.checkedAt, width),
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
handleInput(data: string): void {
|
|
58
|
+
if (this.closed) return;
|
|
59
|
+
if (matchesKey(data, Key.escape) || (this.details && matchesKey(data, "b"))) {
|
|
60
|
+
if (this.details) { this.details = false; this.scroll = 0; }
|
|
61
|
+
else { this.closed = true; this.done(undefined); }
|
|
62
|
+
} else if (!this.details && matchesKey(data, Key.enter)) {
|
|
63
|
+
this.closed = true;
|
|
64
|
+
this.done(this.choices[this.index]?.profile);
|
|
65
|
+
} else if (!this.details && matchesKey(data, "v")) {
|
|
66
|
+
this.details = true;
|
|
67
|
+
this.scroll = 0;
|
|
68
|
+
} else {
|
|
69
|
+
const maximum = this.details ? Math.max(0, this.detailLines(this.width).length - this.capacity())
|
|
70
|
+
: Math.max(0, this.choices.length - 1);
|
|
71
|
+
let position = this.details ? this.scroll : this.index;
|
|
72
|
+
if (matchesKey(data, Key.up)) position--;
|
|
73
|
+
else if (matchesKey(data, Key.down)) position++;
|
|
74
|
+
else if (matchesKey(data, Key.pageUp)) position -= this.capacity();
|
|
75
|
+
else if (matchesKey(data, Key.pageDown)) position += this.capacity();
|
|
76
|
+
else if (matchesKey(data, Key.home)) position = 0;
|
|
77
|
+
else if (matchesKey(data, Key.end)) position = maximum;
|
|
78
|
+
else return; // Enter, refresh and reset controls never act in details.
|
|
79
|
+
position = Math.max(0, Math.min(maximum, position));
|
|
80
|
+
if (this.details) this.scroll = position;
|
|
81
|
+
else this.index = position;
|
|
82
|
+
}
|
|
83
|
+
this.requestRender();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(width: number): string[] {
|
|
87
|
+
this.width = Math.max(1, Math.floor(width));
|
|
88
|
+
const capacity = this.capacity();
|
|
89
|
+
let content: string[];
|
|
90
|
+
let hint: string;
|
|
91
|
+
if (this.details) {
|
|
92
|
+
const lines = this.detailLines(this.width);
|
|
93
|
+
this.scroll = Math.min(this.scroll, Math.max(0, lines.length - capacity));
|
|
94
|
+
content = lines.slice(this.scroll, this.scroll + capacity);
|
|
95
|
+
hint = `esc/b back · ↑/↓ PgUp/PgDn Home/End scroll · ${this.scroll + 1}–${Math.min(lines.length, this.scroll + capacity)}/${lines.length}`;
|
|
96
|
+
} else {
|
|
97
|
+
const start = Math.max(0, this.index - capacity + 1);
|
|
98
|
+
content = this.choices.slice(start, start + capacity).map((choice, offset) =>
|
|
99
|
+
this.theme.fg(start + offset === this.index ? "accent" : "text",
|
|
100
|
+
`${start + offset === this.index ? ">" : " "} ${choice.label}`));
|
|
101
|
+
hint = "↑/↓ highlight · v usage details · enter switch · esc cancel";
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
this.theme.fg("accent", this.details ? `Usage: ${this.choices[this.index]?.profile ?? "unknown"} (read-only)` : "Switch OpenAI account"),
|
|
105
|
+
...content,
|
|
106
|
+
this.theme.fg("muted", hint),
|
|
107
|
+
].slice(0, Math.max(1, this.height())).map(line => truncateToWidth(line, this.width, ""));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
invalidate(): void {} // Output is recomputed with the current theme and viewport.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function selectAccountProfile(
|
|
114
|
+
ui: Pick<ExtensionContext["ui"], "custom">,
|
|
115
|
+
choices: readonly AccountProfileChoice[],
|
|
116
|
+
): Promise<string | undefined> {
|
|
117
|
+
return ui.custom<string | undefined>((tui, theme, _keys, done) => new AccountProfileSelector(
|
|
118
|
+
// Match the SDK's 100%-height, zero-margin overlay viewport on every resize.
|
|
119
|
+
choices, theme, () => Math.max(1, tui.terminal.rows), () => tui.requestRender(), done,
|
|
120
|
+
), {
|
|
121
|
+
overlay: true,
|
|
122
|
+
overlayOptions: { width: "100%", maxHeight: "100%", margin: 0, nonCapturing: false },
|
|
123
|
+
});
|
|
124
|
+
}
|