osdy-pi 1.11.10 → 1.11.11
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 +25 -13
- package/extensions/osdy-pi/account-profiles.test.ts +43 -1
- package/extensions/osdy-pi/account-profiles.ts +11 -5
- package/extensions/osdy-pi/constants.ts +3 -3
- package/extensions/osdy-pi/control-center-account.test.ts +81 -0
- package/extensions/osdy-pi/control-center-account.ts +1 -1
- package/extensions/osdy-pi/runtime.test.ts +244 -42
- package/extensions/osdy-pi/runtime.ts +67 -51
- package/extensions/osdy-pi/ui.test.ts +16 -7
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
12
12
|
|
|
13
13
|
| Area | What ships |
|
|
14
14
|
| --- | --- |
|
|
15
|
-
| Control Center | `/osdyConfig` opens ten categories, including confirmed TODO/Agents provider selection and explicit per-profile Codex quota previews. |
|
|
15
|
+
| Control Center | **Ctrl+Option+O** or `/osdyConfig` opens ten categories, including confirmed TODO/Agents provider selection and explicit per-profile Codex quota previews. |
|
|
16
16
|
| 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. |
|
|
17
17
|
| Themes introduced in 1.11.0 | Spider-Man Classic, Miles Morales, and Spider-Verse add three distinct dark palettes. |
|
|
18
18
|
| Themes introduced in 1.9.0 | Halloween and Halloween Killer add two dark palettes. |
|
|
@@ -20,7 +20,7 @@ Osdy Pi gives [Pi](https://github.com/earendil-works/pi) a themed, responsive te
|
|
|
20
20
|
| Message cards | Pi-native colored user cards without emojis and plain assistant Markdown preceded by a separate `🦝` transcript row. |
|
|
21
21
|
| Header and mascot | Independent header/mascot choices, an installed-package version label, and mascot-only fallback when full artwork cannot fit. |
|
|
22
22
|
| Accounts | `/osdy-account` switches Codex profiles in place with atomic activation, rollback, bounded auth files, and process-safe locks. |
|
|
23
|
-
| Quota | `/usage`
|
|
23
|
+
| Quota | **Ctrl+Option+U** or `/usage` opens subscription quota; compact bars emphasize remaining Codex quota at warning (40% or less) and error (15% or less) thresholds. |
|
|
24
24
|
| TODO (starting in 1.5.0, opt-in) | Default-off first-party `todo` snapshots track the Pi session branch; `/todos` reads them without editing. ODD Markdown remains separate. |
|
|
25
25
|
|
|
26
26
|
## Prerequisites
|
|
@@ -57,11 +57,22 @@ Osdy Pi's own extension and themes are bundled—do not install them separately.
|
|
|
57
57
|
|
|
58
58
|
### Control Center in Pi
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Press **Ctrl+Option+O** or 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
|
+
|
|
62
|
+
| Shortcut | Panel | Command fallback |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| **Ctrl+Option+O** | Osdy Control Center | `/osdyConfig` |
|
|
65
|
+
| **Ctrl+Option+U** | Active Codex subscription quota—not local token/cost analytics | `/usage` |
|
|
66
|
+
|
|
67
|
+
On macOS, **Option = Alt** in Pi's shortcut names: these bindings register as `ctrl+alt+o` and `ctrl+alt+u`. On other platforms, use **Ctrl+Alt+O** and **Ctrl+Alt+U**. Your terminal must send Option as **Alt/Meta** and support **Ctrl+Alt chords**; registration does not guarantee actual hardware delivery. Check **`/hotkeys`** for bindings, and use **`/osdyConfig`** or **`/usage`** if the terminal, OS, or another extension intercepts or cannot deliver the chord.
|
|
68
|
+
|
|
69
|
+
**Ctrl+Option+O capability limits:** Account **Switch**, **Set default**, and **Clear default** refuse changes while Pi is busy; retry when idle. The same actions through `/osdyConfig` retain idle waiting, as does switching through `/osdy-account`. Read-only account quota previews remain available while busy. Successful TODO/Agents selection from Ctrl+Option+O saves settings, closes/disposes the panel, releases its editor hold, then asks you to run **`/reload` manually** (or restart Pi). From `/osdyConfig`, the same sequence still reloads automatically once; restart Pi if reload fails.
|
|
70
|
+
|
|
71
|
+
Ctrl+Option+U opens the existing `/usage` dashboard with the same refresh behavior; it does not open `/osdy-usage`.
|
|
61
72
|
|
|
62
73
|
- 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
|
-
- 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
|
|
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
|
|
74
|
+
- 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 automatic command reload or the Ctrl+Option+O manual-reload notice described above; 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.
|
|
75
|
+
- 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 automatic command reload or the Ctrl+Option+O manual-reload notice described above; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
|
|
65
76
|
- 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
77
|
- Header offers **osdy-theme / neon**; Mascot offers **current / Bts / Osdy-Halloween**; 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
78
|
- 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.
|
|
@@ -69,7 +80,7 @@ Run **`/osdyConfig`** in interactive Pi to open the Osdy Control Center. The ten
|
|
|
69
80
|
- **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
81
|
- **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
82
|
- 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** 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
|
|
83
|
+
- **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.
|
|
73
84
|
- **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
85
|
- **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
86
|
|
|
@@ -402,7 +413,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
|
|
|
402
413
|
|
|
403
414
|
| Group | Command |
|
|
404
415
|
| --- | --- |
|
|
405
|
-
| Control Center | `/osdyConfig` (interactive terminal UI) |
|
|
416
|
+
| Control Center | **Ctrl+Option+O** or `/osdyConfig` (interactive terminal UI) |
|
|
406
417
|
| Main | `/osdy-pi` |
|
|
407
418
|
| Main | `/osdy-pi enable\|disable\|on\|off\|status` |
|
|
408
419
|
| Accounts | `/osdy-account` |
|
|
@@ -416,7 +427,7 @@ The enabled state, editor mode, working-tree visibility preference, header, and
|
|
|
416
427
|
| Audio | `/osdy-pi sound setup` |
|
|
417
428
|
| Diff | `/osdy-pi diff` |
|
|
418
429
|
| Package | `/osdy-pi uninstall` |
|
|
419
|
-
| Codex subscription | `/usage` |
|
|
430
|
+
| Codex subscription | **Ctrl+Option+U** or `/usage` |
|
|
420
431
|
| Local usage analytics (starting in 1.10.0) | `/osdy-usage` |
|
|
421
432
|
|
|
422
433
|
`/osdy-pi uninstall` finds a unique Osdy Pi package in Pi's configured package list and asks you to confirm its exact source and user/project scope before calling Pi's `remove` command. Run it from a trusted project with interactive UI. Git registrations are checked against Pi's host/path identity; a local registration must point to this running extension's package root and have a matching manifest. Ambiguous or changed registrations, unrecognized sources, and cancelled confirmation remove nothing. It removes only the Pi package registration, not Osdy profiles, accounts, the globally installed CLI, or other packages. Restart Pi afterward to unload the extension. If no unique source is found, inspect `pi list` and use `pi remove <source> [-l]` manually.
|
|
@@ -425,13 +436,13 @@ The enabled state, editor mode, working-tree visibility preference, header, and
|
|
|
425
436
|
|
|
426
437
|
### Codex subscription usage
|
|
427
438
|
|
|
428
|
-
|
|
439
|
+
Press **Ctrl+Option+U** or run `/usage` to open the same Codex subscription dashboard for the active managed `openai-codex` profile and model. Press `r` to refresh; `esc` or `q` closes it. The dashboard shows these controls at the bottom.
|
|
429
440
|
|
|
430
441
|
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.
|
|
431
442
|
|
|
432
443
|
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.
|
|
433
444
|
|
|
434
|
-
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.
|
|
445
|
+
Active quota loads when an enabled session starts and refreshes when **Ctrl+Option+U** or `/usage` opens the dashboard, 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.
|
|
435
446
|
|
|
436
447
|
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.
|
|
437
448
|
|
|
@@ -606,13 +617,14 @@ This launches `pi` from `PATH`, loads this checkout with `-e`, and uses `.pi-dev
|
|
|
606
617
|
The explicit argument bypasses the launcher's default-account activation; Pi's `--no-session` disables session persistence, **not settings writes or provider access**.
|
|
607
618
|
An isolated agent directory does **not** make custom audio paths or real provider accounts harmless. Use intentional disposable test profiles/files for profile writes, switches, default confirmations, and audio tests; do not assume an isolated account store makes real credentials safe.
|
|
608
619
|
|
|
609
|
-
- 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.
|
|
620
|
+
- Check `/hotkeys` lists `ctrl+alt+o` and `ctrl+alt+u`. On macOS, configure Option to send Alt/Meta and verify the terminal supports Ctrl+Alt chords; actual Ctrl+Option delivery still needs a hardware check. Open both **Ctrl+Option+O** and `/osdyConfig` in regular and fullscreen Pi (Ctrl+Alt+O on other platforms); use the slash command if the chord is intercepted or unsupported. Check arrows/Home/End/Page Up/Down, Tab or left/right, Enter, and Esc; shrink and restore the terminal, checking both panes remain reachable. With disposable TODO/Agents settings, verify Ctrl+Option+O asks for manual `/reload` only after closing, while `/osdyConfig` reloads automatically.
|
|
610
621
|
- 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.
|
|
611
|
-
- 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`.
|
|
622
|
+
- 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`.
|
|
623
|
+
- 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.
|
|
612
624
|
- 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.
|
|
613
625
|
- 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.
|
|
614
626
|
|
|
615
|
-
Live TUI appearance, real account switching/authenticated quota, and actual audio playback remain unverified; this checklist is not a recorded pass.
|
|
627
|
+
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. Live TUI appearance, real account switching/authenticated quota, and actual audio playback remain unverified; this checklist is not a recorded full pass.
|
|
616
628
|
|
|
617
629
|
Install a local checkout into Pi with:
|
|
618
630
|
|
|
@@ -460,12 +460,14 @@ void test("manages profile rename and permanent removal prompts through authorit
|
|
|
460
460
|
|
|
461
461
|
void test("switches in place after idle without spawning or shutting down", async () => {
|
|
462
462
|
const events: string[] = [];
|
|
463
|
+
let idle = false;
|
|
463
464
|
await switchAccountInPlace(
|
|
464
465
|
{
|
|
465
|
-
isIdle: () =>
|
|
466
|
+
isIdle: () => idle,
|
|
466
467
|
waitForIdle: () =>
|
|
467
468
|
Promise.resolve().then(() => {
|
|
468
469
|
events.push("idle");
|
|
470
|
+
idle = true;
|
|
469
471
|
}),
|
|
470
472
|
ui: {
|
|
471
473
|
notify: (message: string) => events.push(message),
|
|
@@ -495,6 +497,46 @@ void test("switches in place after idle without spawning or shutting down", asyn
|
|
|
495
497
|
]);
|
|
496
498
|
});
|
|
497
499
|
|
|
500
|
+
void test("base-context account switches require idle without a command wait hook", async () => {
|
|
501
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
502
|
+
try {
|
|
503
|
+
for (const idle of [false, true]) {
|
|
504
|
+
process.env.OSDY_PI_PROFILE_NAME = "personal";
|
|
505
|
+
const events: string[] = [];
|
|
506
|
+
const notices: string[] = [];
|
|
507
|
+
const ctx = { isIdle: () => idle, ui: {
|
|
508
|
+
notify: (message: string) => notices.push(message),
|
|
509
|
+
select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined),
|
|
510
|
+
} };
|
|
511
|
+
assert.equal("waitForIdle" in ctx, false);
|
|
512
|
+
const switched = await switchAccountInPlace(ctx, "work",
|
|
513
|
+
profile => { events.push(`activate:${profile}`); return Promise.resolve(); },
|
|
514
|
+
() => { events.push("refresh"); return Promise.resolve(); },
|
|
515
|
+
() => { events.push("render"); });
|
|
516
|
+
assert.equal(switched, idle);
|
|
517
|
+
assert.deepEqual(events, idle ? ["activate:work", "render", "refresh"] : []);
|
|
518
|
+
assert.equal(process.env.OSDY_PI_PROFILE_NAME, idle ? "work" : "personal");
|
|
519
|
+
assert.match(notices.join(" "), idle ? /Switched to work/ : /require Pi to be idle/);
|
|
520
|
+
}
|
|
521
|
+
} finally {
|
|
522
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
523
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
void test("a command wait hook cannot activate an account if Pi remains busy", async () => {
|
|
528
|
+
const events: string[] = [];
|
|
529
|
+
const switched = await switchAccountInPlace({ isIdle: () => false,
|
|
530
|
+
waitForIdle: () => { events.push("wait"); return Promise.resolve(); },
|
|
531
|
+
ui: { notify: message => events.push(message), select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) },
|
|
532
|
+
}, "work", () => { events.push("activate"); return Promise.resolve(); },
|
|
533
|
+
() => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); });
|
|
534
|
+
assert.equal(switched, false);
|
|
535
|
+
assert.equal(events[0], "wait");
|
|
536
|
+
assert.match(events[1] ?? "", /require Pi to be idle/);
|
|
537
|
+
assert.equal(events.length, 2);
|
|
538
|
+
});
|
|
539
|
+
|
|
498
540
|
void test("keeps the current account when in-place activation fails", async () => {
|
|
499
541
|
const notices: string[] = [];
|
|
500
542
|
let renderRequested = false;
|
|
@@ -12,10 +12,9 @@ function sameProfile(left: string | undefined, right: string | undefined): boole
|
|
|
12
12
|
return left?.toLowerCase() === right?.toLowerCase();
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export type AccountContext = Pick<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
> & {
|
|
15
|
+
export type AccountContext = Pick<ExtensionContext, "isIdle"> & {
|
|
16
|
+
/** Only slash-command contexts can wait; base shortcut contexts must already be idle. */
|
|
17
|
+
waitForIdle?: ExtensionCommandContext["waitForIdle"];
|
|
19
18
|
shutdown?: ExtensionCommandContext["shutdown"];
|
|
20
19
|
sessionManager?: Pick<
|
|
21
20
|
ExtensionCommandContext["sessionManager"],
|
|
@@ -128,7 +127,14 @@ export async function switchAccountInPlace(
|
|
|
128
127
|
);
|
|
129
128
|
return false;
|
|
130
129
|
}
|
|
131
|
-
if (!ctx.isIdle()) await ctx.waitForIdle();
|
|
130
|
+
if (!ctx.isIdle() && ctx.waitForIdle) await ctx.waitForIdle();
|
|
131
|
+
if (!ctx.isIdle()) {
|
|
132
|
+
ctx.ui.notify(
|
|
133
|
+
"Account changes require Pi to be idle. Wait for active work to finish and retry.",
|
|
134
|
+
"warning",
|
|
135
|
+
);
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
132
138
|
try {
|
|
133
139
|
await activate(profile);
|
|
134
140
|
} catch {
|
|
@@ -458,11 +458,11 @@ function addRightEdgeGlow(art: MascotArt): MascotArt {
|
|
|
458
458
|
}
|
|
459
459
|
|
|
460
460
|
// Transparent overlay at Current's mouth/shoulders (row 17 onward).
|
|
461
|
-
// ▓ = dark cape, ▒ = crimson lining
|
|
461
|
+
// ▓ = dark cape, ▒ = crimson lining. Keep the face and tail.
|
|
462
462
|
const VAMPIRE_COSTUME = [
|
|
463
463
|
" ▓ ▓",
|
|
464
|
-
" ▓▒▓
|
|
465
|
-
" ▓▒▒▒▓
|
|
464
|
+
" ▓▒▓ ▓▒▒▓",
|
|
465
|
+
" ▓▒▒▒▓ ▓▒▒▒▒▓",
|
|
466
466
|
" ▓▒▒▒▒▒▒▓ ▓▒▒▒▒▒▒▓",
|
|
467
467
|
" ▓▓▒▒▒▒▒▓▓ ▓▓▒▒▒▒▒▓▓",
|
|
468
468
|
" ▓▓▓▒▒▒▓▓ ▓▓▒▒▒▓▓▓",
|
|
@@ -138,6 +138,87 @@ void test("Bound Account reuses idle switching, quota refresh, and authoritative
|
|
|
138
138
|
}
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
void test("Bound base-context Account mutations refuse busy Pi and work when idle", async () => {
|
|
142
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
143
|
+
try {
|
|
144
|
+
for (const idle of [false, true]) {
|
|
145
|
+
process.env.OSDY_PI_PROFILE_NAME = "personal";
|
|
146
|
+
const events: string[] = [];
|
|
147
|
+
const commands: string[][] = [];
|
|
148
|
+
const ctx = { isIdle: () => idle, ui: {
|
|
149
|
+
notify: (text: string) => events.push(text),
|
|
150
|
+
select: () => Promise.reject(new Error("dialog forbidden")), input: () => Promise.reject(new Error("dialog forbidden")),
|
|
151
|
+
} };
|
|
152
|
+
assert.equal("waitForIdle" in ctx, false);
|
|
153
|
+
const service = bindControlCenterAccount(ctx,
|
|
154
|
+
() => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); },
|
|
155
|
+
{ profiles: () => Promise.resolve(["work", "personal"]),
|
|
156
|
+
activate: name => { events.push(`activate:${name}`); return Promise.resolve(); },
|
|
157
|
+
run: args => { commands.push(args); return Promise.resolve({ code: 0, stdout: "No default account.", stderr: "" }); } });
|
|
158
|
+
assert.equal((await service.apply({ kind: "account-switch", profile: "work" })).failed, !idle);
|
|
159
|
+
assert.equal((await service.apply({ kind: "account-default", profile: "work" })).failed, !idle);
|
|
160
|
+
assert.equal((await service.apply({ kind: "account-default", profile: undefined })).failed, !idle);
|
|
161
|
+
assert.deepEqual(commands, idle ? [["account", "default", "work"], ["account", "default", "--clear"]] : []);
|
|
162
|
+
assert.equal(events.includes("activate:work"), idle);
|
|
163
|
+
assert.equal(events.includes("render"), idle);
|
|
164
|
+
assert.equal(events.includes("refresh"), idle);
|
|
165
|
+
assert.equal(process.env.OSDY_PI_PROFILE_NAME, idle ? "work" : "personal");
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
169
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
void test("Bound command Account defaults wait for idle and recheck idle and runtime freshness", async () => {
|
|
174
|
+
for (const outcome of ["idle", "busy", "stale", "rejected"] as const) {
|
|
175
|
+
let idle = false;
|
|
176
|
+
let current = true;
|
|
177
|
+
const events: string[] = [];
|
|
178
|
+
const service = bindControlCenterAccount({ isIdle: () => idle,
|
|
179
|
+
waitForIdle: () => {
|
|
180
|
+
events.push("wait");
|
|
181
|
+
idle = outcome !== "busy";
|
|
182
|
+
current = outcome !== "stale";
|
|
183
|
+
return outcome === "rejected" ? Promise.reject(new Error("wait failed")) : Promise.resolve();
|
|
184
|
+
},
|
|
185
|
+
ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) },
|
|
186
|
+
}, () => { throw new Error("refresh forbidden"); }, () => {},
|
|
187
|
+
{ profiles: () => Promise.resolve(["work"]), run: args => {
|
|
188
|
+
events.push(args.join(" ")); return Promise.resolve({ code: 0, stdout: "", stderr: "" });
|
|
189
|
+
} }, { isCurrent: () => current });
|
|
190
|
+
assert.equal((await service.apply({ kind: "account-default", profile: "work" })).failed, outcome !== "idle");
|
|
191
|
+
assert.deepEqual(events, outcome === "idle" ? ["wait", "account default work"] : ["wait"]);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
void test("Bound base-context Account rechecks idle after asynchronous profile validation", async () => {
|
|
196
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
197
|
+
process.env.OSDY_PI_PROFILE_NAME = "personal";
|
|
198
|
+
let idle = true;
|
|
199
|
+
let finish: (profiles: string[]) => void = () => {};
|
|
200
|
+
const events: string[] = [];
|
|
201
|
+
const service = bindControlCenterAccount({ isIdle: () => idle,
|
|
202
|
+
ui: { notify: () => {}, select: () => Promise.resolve(undefined), input: () => Promise.resolve(undefined) },
|
|
203
|
+
}, () => { events.push("refresh"); return Promise.resolve(); }, () => { events.push("render"); },
|
|
204
|
+
{ profiles: () => new Promise(resolve => { finish = resolve; }),
|
|
205
|
+
activate: () => { events.push("activate"); return Promise.resolve(); },
|
|
206
|
+
run: () => { events.push("default"); return Promise.resolve({ code: 0, stdout: "", stderr: "" }); } });
|
|
207
|
+
try {
|
|
208
|
+
for (const kind of ["account-switch", "account-default"] as const) {
|
|
209
|
+
idle = true;
|
|
210
|
+
const applying = service.apply({ kind, profile: "work" });
|
|
211
|
+
idle = false;
|
|
212
|
+
finish(["work"]);
|
|
213
|
+
assert.equal((await applying).failed, true);
|
|
214
|
+
}
|
|
215
|
+
assert.deepEqual(events, []);
|
|
216
|
+
} finally {
|
|
217
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
218
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
141
222
|
void test("Bound Account validates live external active names without rendering unsafe metadata", async () => {
|
|
142
223
|
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
143
224
|
const service = bindControlCenterAccount({ isIdle: () => true, waitForIdle: () => Promise.resolve(),
|
|
@@ -136,7 +136,7 @@ export function bindControlCenterAccount(ctx: AccountContext, refreshUsage: () =
|
|
|
136
136
|
await backend.activate!(name);
|
|
137
137
|
}, refreshUsage, requestRender) : Promise.resolve(false),
|
|
138
138
|
setDefault: async profile => {
|
|
139
|
-
if (!ctx.isIdle()) await ctx.waitForIdle();
|
|
139
|
+
if (!ctx.isIdle() && ctx.waitForIdle) await ctx.waitForIdle();
|
|
140
140
|
if (!(options.isCurrent?.() ?? true) || !ctx.isIdle()) return false;
|
|
141
141
|
return (await backend.run(["account", "default", profile ?? "--clear"])).code === 0;
|
|
142
142
|
},
|
|
@@ -47,6 +47,8 @@ registerHooks({
|
|
|
47
47
|
},
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
type ShortcutRegistration = Parameters<ExtensionAPI["registerShortcut"]>[1];
|
|
51
|
+
|
|
50
52
|
const profileLabels = await import("./profile-label.js");
|
|
51
53
|
const { showControlCenter } = await import("./control-center.js");
|
|
52
54
|
|
|
@@ -60,7 +62,7 @@ void test("registered Account factory isolates profile preview from active quota
|
|
|
60
62
|
let signal: AbortSignal | undefined; let finish = () => {}; let factoryCalls = 0; let renderCount = 0;
|
|
61
63
|
let current: (() => boolean) | undefined;
|
|
62
64
|
const pi = { registerCommand: (name: string, command: { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }) => commands.set(name, command),
|
|
63
|
-
on: () => {}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {}, events: { on: () => () => {} },
|
|
65
|
+
registerShortcut: () => {}, on: () => {}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {}, events: { on: () => () => {} },
|
|
64
66
|
exec: () => { throw new Error("launcher or provider forbidden"); } } as unknown as ExtensionAPI;
|
|
65
67
|
const ctx = { cwd: process.cwd(), mode: "tui", hasUI: true, isIdle: () => true, waitForIdle: () => Promise.resolve(), ui: {
|
|
66
68
|
notify: (text: string) => notices.push(text),
|
|
@@ -197,7 +199,7 @@ void test("shared visual actions apply immediately, save the complete settings,
|
|
|
197
199
|
const { isSmallResponsiveMode } = await import("./utils.js");
|
|
198
200
|
const { createResponsiveCoordinator, reconcileResponsiveUi, disableOsdyPi } = await import("./runtime-helpers.js");
|
|
199
201
|
|
|
200
|
-
async function focusFixture(options: { boundary?: boolean; throwApply?: boolean; synchronousThrow?: boolean; underlyingOverlay?: boolean; startupFailure?: boolean; registered?: boolean; todoDir?: string; reloadFails?: boolean;
|
|
202
|
+
async function focusFixture(options: { boundary?: boolean; throwApply?: boolean; synchronousThrow?: boolean; underlyingOverlay?: boolean; startupFailure?: boolean; registered?: boolean; shortcut?: boolean; idle?: boolean; todoDir?: string; reloadFails?: boolean;
|
|
201
203
|
accountFactory?: NonNullable<Parameters<typeof registerOsdyPi>[1]>["accountFactory"] } = {}) {
|
|
202
204
|
let input: (data: string) => void = () => {};
|
|
203
205
|
const columns = options.boundary
|
|
@@ -237,7 +239,7 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
|
|
|
237
239
|
let panel: Component & { dispose?(): void };
|
|
238
240
|
let handle: OverlayHandle;
|
|
239
241
|
let otherHandle: OverlayHandle | undefined;
|
|
240
|
-
let complete: () => void = () => {};
|
|
242
|
+
let complete: (result?: unknown) => void = () => {};
|
|
241
243
|
let current = true;
|
|
242
244
|
let headerMounts = 0;
|
|
243
245
|
const writes: GlobalEditorSettings[] = [];
|
|
@@ -246,8 +248,8 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
|
|
|
246
248
|
const notices: string[] = [];
|
|
247
249
|
const theme = { name: "dark", appearance: "dark" as const, fg: (_color: string, text: string) => text };
|
|
248
250
|
const lifecycle: string[] = [];
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
let idle = options.idle ?? true;
|
|
252
|
+
const ctx = { cwd: options.todoDir ?? "/synthetic", isIdle: () => idle,
|
|
251
253
|
hasUI: true, mode: "tui", modelRegistry: { getProviderAuth: () => Promise.resolve(undefined) }, ui: {
|
|
252
254
|
theme, getAllThemes: () => [{ name: "dark", path: undefined }],
|
|
253
255
|
setTheme: () => { theme.name = "light"; panel.invalidate(); return { success: true }; },
|
|
@@ -275,7 +277,7 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
|
|
|
275
277
|
lifecycle.push("done"); closes++; tui.hideOverlay(); lifecycle.push("hidden");
|
|
276
278
|
resolve(result as T); panel.dispose?.(); lifecycle.push("disposed");
|
|
277
279
|
};
|
|
278
|
-
complete =
|
|
280
|
+
complete = result => finish(result);
|
|
279
281
|
panel = factory(tui, theme as Theme, undefined as never, finish) as typeof panel;
|
|
280
282
|
const overlayOptions = customOptions?.overlayOptions;
|
|
281
283
|
handle = tui.showOverlay(panel, typeof overlayOptions === "function" ? overlayOptions() : overlayOptions);
|
|
@@ -285,6 +287,12 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
|
|
|
285
287
|
} };
|
|
286
288
|
tui.start();
|
|
287
289
|
const runtimeCtx = ctx as unknown as ExtensionContext;
|
|
290
|
+
const commandCtx = { ...ctx,
|
|
291
|
+
waitForIdle: () => { lifecycle.push("idle"); idle = true; return Promise.resolve(); },
|
|
292
|
+
reload: () => {
|
|
293
|
+
lifecycle.push("reload"); return options.reloadFails ? Promise.reject(new Error("reload failed")) : Promise.resolve();
|
|
294
|
+
},
|
|
295
|
+
} as unknown as ExtensionCommandContext;
|
|
288
296
|
const coordinator = createResponsiveCoordinator({} as ExtensionAPI, runtimeCtx, state, tree);
|
|
289
297
|
coordinator.start();
|
|
290
298
|
const showDirect = () => withEditorMountHold({} as ExtensionAPI, runtimeCtx, state, tree, async () => { await showControlCenter(runtimeCtx, {
|
|
@@ -303,8 +311,10 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
|
|
|
303
311
|
if (options.registered) {
|
|
304
312
|
coordinator.stop();
|
|
305
313
|
const commands = new Map<string, { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }>();
|
|
314
|
+
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
306
315
|
const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => Promise<void> | void>>();
|
|
307
316
|
const pi = { registerCommand: (name: string, command: { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }) => commands.set(name, command),
|
|
317
|
+
registerShortcut: (key: string, shortcut: ShortcutRegistration) => shortcuts.set(key, shortcut),
|
|
308
318
|
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) => {
|
|
309
319
|
const registered = handlers.get(name) ?? []; registered.push(handler); handlers.set(name, registered);
|
|
310
320
|
},
|
|
@@ -320,12 +330,23 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
|
|
|
320
330
|
} finally {
|
|
321
331
|
if (previousDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousDir;
|
|
322
332
|
}
|
|
333
|
+
shutdown = () => { void handlers.get("session_shutdown")?.[1]?.({}, runtimeCtx); };
|
|
334
|
+
if (options.shortcut) {
|
|
335
|
+
try {
|
|
336
|
+
assert.ok(shortcuts.has("ctrl+alt+o"), "Ctrl+Alt+O must open Control Center");
|
|
337
|
+
assert.equal(shortcuts.has("f1"), false);
|
|
338
|
+
assert.equal(shortcuts.has("f2"), false);
|
|
339
|
+
assert.equal("reload" in runtimeCtx, false, "shortcuts receive only the base context");
|
|
340
|
+
assert.equal("waitForIdle" in runtimeCtx, false, "shortcuts cannot wait through command-only hooks");
|
|
341
|
+
} catch (error) { shutdown(); tui.stop(); throw error; }
|
|
342
|
+
}
|
|
323
343
|
await handlers.get("session_start")?.[1]?.({}, runtimeCtx);
|
|
324
344
|
replacements = 0;
|
|
325
345
|
headerMounts = 0;
|
|
326
|
-
shutdown = () => { void handlers.get("session_shutdown")?.[1]?.({}, runtimeCtx); };
|
|
327
346
|
transition = async () => { await handlers.get("session_start")?.[1]?.({}, runtimeCtx); };
|
|
328
|
-
reopen =
|
|
347
|
+
reopen = options.shortcut
|
|
348
|
+
? async () => { await shortcuts.get("ctrl+alt+o")!.handler(runtimeCtx); }
|
|
349
|
+
: () => commands.get("osdyConfig")!.handler("", commandCtx);
|
|
329
350
|
}
|
|
330
351
|
if (options.underlyingOverlay) otherHandle = tui.showOverlay({ render: () => ["Other overlay"], invalidate: () => {} });
|
|
331
352
|
const showing = reopen();
|
|
@@ -337,12 +358,13 @@ async function focusFixture(options: { boundary?: boolean; throwApply?: boolean;
|
|
|
337
358
|
disable: () => { state.enabled = false; disableOsdyPi(runtimeCtx, state); },
|
|
338
359
|
reconcile: () => reconcileResponsiveUi({} as ExtensionAPI, runtimeCtx, state, tree), send: (data: string) => input(data), text: () => panel.render(100).join("\n"),
|
|
339
360
|
focused: () => handle.isFocused(), replacements: () => replacements, closes: () => closes,
|
|
340
|
-
resolveSave: () => resolveSave(), rejectSave: () => rejectSave(), complete: () => complete(),
|
|
361
|
+
resolveSave: () => resolveSave(), rejectSave: () => rejectSave(), complete: (result?: unknown) => complete(result),
|
|
362
|
+
setIdle: (value: boolean) => { idle = value; },
|
|
341
363
|
editors, mountedEditor: () => editors.find(candidate => editorArea.children.includes(candidate))!,
|
|
342
364
|
other: () => otherHandle, cleanup: () => { shutdown(); coordinator.stop(); panel?.dispose?.(); handle?.hide(); otherHandle?.hide(); tui.stop(); } };
|
|
343
365
|
}
|
|
344
|
-
for (const closePending of [false, true]) {
|
|
345
|
-
void test(`registered Account real TUI ${closePending ? "aborts pending preview and ignores late completion" : "renders selected profile quota before Escape restores mounted input"}`, async () => {
|
|
366
|
+
for (const shortcut of [false, true]) for (const closePending of [false, true]) {
|
|
367
|
+
void test(`registered ${shortcut ? "Ctrl+Alt+O" : "command"} Account real TUI ${closePending ? "aborts pending preview and ignores late completion" : "renders selected profile quota before Escape restores mounted input"}`, async () => {
|
|
346
368
|
const { bindControlCenterAccount } = await import("./control-center-account.js");
|
|
347
369
|
const queries: string[] = []; const metadataCalls: string[][] = [];
|
|
348
370
|
let signal: AbortSignal | undefined;
|
|
@@ -354,7 +376,7 @@ for (const closePending of [false, true]) {
|
|
|
354
376
|
quotaSnapshot: { fetchedAt: checkedAt, planType: "plus", ordinaryUsageAllowed: true, credits: undefined,
|
|
355
377
|
buckets: [{ id: "codex", label: "Codex", primary: { usedPercent: 37, windowMinutes: 300, resetsAt }, secondary: undefined }] },
|
|
356
378
|
};
|
|
357
|
-
const f = await focusFixture({ registered: true, accountFactory: (context, _refresh, render, _backend, options) => {
|
|
379
|
+
const f = await focusFixture({ registered: true, shortcut, idle: false, accountFactory: (context, _refresh, render, _backend, options) => {
|
|
358
380
|
factoryCalls++;
|
|
359
381
|
return bindControlCenterAccount(context, () => { activeRefreshes++; throw new Error("active quota refresh forbidden"); }, render,
|
|
360
382
|
{ profiles: () => Promise.resolve(["personal", "work"]),
|
|
@@ -425,12 +447,73 @@ for (const closePending of [false, true]) {
|
|
|
425
447
|
});
|
|
426
448
|
}
|
|
427
449
|
|
|
428
|
-
void test(
|
|
429
|
-
|
|
450
|
+
for (const shortcut of [false, true]) void test(`registered ${shortcut ? "Ctrl+Alt+O" : "command"} Account confirms mutations and respects its idle capability`, async () => {
|
|
451
|
+
const { bindControlCenterAccount } = await import("./control-center-account.js");
|
|
452
|
+
const previous = process.env.OSDY_PI_PROFILE_NAME;
|
|
453
|
+
try {
|
|
454
|
+
for (const initialIdle of [false, true]) {
|
|
455
|
+
process.env.OSDY_PI_PROFILE_NAME = "personal";
|
|
456
|
+
let defaultProfile: string | undefined = "personal";
|
|
457
|
+
const mutations: string[] = [];
|
|
458
|
+
const f = await focusFixture({ registered: true, shortcut, idle: initialIdle,
|
|
459
|
+
accountFactory: (context, _refresh, render, _backend, options) => {
|
|
460
|
+
assert.equal(typeof context.waitForIdle, shortcut ? "undefined" : "function");
|
|
461
|
+
return bindControlCenterAccount(context,
|
|
462
|
+
() => { mutations.push("refresh"); return Promise.resolve(); }, render,
|
|
463
|
+
{ profiles: () => Promise.resolve(["work", "personal"]),
|
|
464
|
+
activate: profile => { mutations.push(`switch:${profile}`); return Promise.resolve(); },
|
|
465
|
+
run: args => {
|
|
466
|
+
if (args.length === 3) {
|
|
467
|
+
mutations.push(`default:${args[2]}`);
|
|
468
|
+
defaultProfile = args[2] === "--clear" ? undefined : args[2];
|
|
469
|
+
}
|
|
470
|
+
return Promise.resolve({ code: 0, stdout: defaultProfile ?? "No default account.", stderr: "" });
|
|
471
|
+
},
|
|
472
|
+
}, options);
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
try {
|
|
476
|
+
for (let i = 0; i < 6; i++) f.send("\x1b[B");
|
|
477
|
+
await flushFocus(); await flushFocus();
|
|
478
|
+
f.send("\x1b[C"); f.send("\x1b[H");
|
|
479
|
+
assert.match(f.text(), /> work/);
|
|
480
|
+
const refused = shortcut && !initialIdle;
|
|
481
|
+
for (const key of ["s", "d", "c"]) {
|
|
482
|
+
f.setIdle(initialIdle);
|
|
483
|
+
const beforeCancel = [...mutations];
|
|
484
|
+
f.send(key);
|
|
485
|
+
assert.match(f.text(), /> Cancel/);
|
|
486
|
+
f.send("\r");
|
|
487
|
+
assert.deepEqual(mutations, beforeCancel, "Cancel never mutates");
|
|
488
|
+
f.send(key); f.text(); f.send("\x1b[B"); f.send("\r");
|
|
489
|
+
await flushFocus(); await flushFocus();
|
|
490
|
+
assert.match(f.text(), refused ? /Account operation failed; change not confirmed/ : key === "s" ? /Account switched/ : /Default account saved/);
|
|
491
|
+
assert.equal(f.closes(), 0, "Account changes keep the panel open");
|
|
492
|
+
}
|
|
493
|
+
assert.deepEqual(mutations, refused ? [] : ["switch:work", "refresh", "default:work", "default:--clear"]);
|
|
494
|
+
assert.equal(process.env.OSDY_PI_PROFILE_NAME, refused ? "personal" : "work");
|
|
495
|
+
assert.equal(defaultProfile, refused ? "personal" : undefined);
|
|
496
|
+
assert.equal(f.lifecycle.filter(event => event === "idle").length, !shortcut && !initialIdle ? 3 : 0);
|
|
497
|
+
f.send("\x1b"); await f.showing;
|
|
498
|
+
assert.equal(f.tui.hasOverlay(), false);
|
|
499
|
+
assert.ok(!f.lifecycle.includes("reload"));
|
|
500
|
+
assertEditorContinuity(f);
|
|
501
|
+
} finally { f.cleanup(); }
|
|
502
|
+
}
|
|
503
|
+
} finally {
|
|
504
|
+
if (previous === undefined) delete process.env.OSDY_PI_PROFILE_NAME;
|
|
505
|
+
else process.env.OSDY_PI_PROFILE_NAME = previous;
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
void test("registered TODO selection reloads from the command but Ctrl+Alt+O requests manual reload after disposal and hold release", async () => {
|
|
510
|
+
for (const { shortcut, reloadFails } of [
|
|
511
|
+
{ shortcut: false, reloadFails: false }, { shortcut: false, reloadFails: true }, { shortcut: true, reloadFails: false },
|
|
512
|
+
]) {
|
|
430
513
|
const previousDir = process.env.PI_CODING_AGENT_DIR;
|
|
431
514
|
const agentDir = mkdtempSync(join(tmpdir(), "osdy-cc07-runtime-"));
|
|
432
515
|
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
433
|
-
const f = await focusFixture({ registered: true, todoDir: agentDir, reloadFails });
|
|
516
|
+
const f = await focusFixture({ registered: true, shortcut, todoDir: agentDir, reloadFails });
|
|
434
517
|
try {
|
|
435
518
|
f.lifecycle.length = 0;
|
|
436
519
|
// Queue simple editor while this real overlay still owns input.
|
|
@@ -447,10 +530,13 @@ void test("registered TODO reload follows public done, SDK hide/dispose and defe
|
|
|
447
530
|
assert.match(f.text(), /Reload follows/);
|
|
448
531
|
f.send("\x1b[B"); f.send("\r");
|
|
449
532
|
await f.showing;
|
|
450
|
-
assert.deepEqual(f.lifecycle, ["done", "hidden", "disposed", "editor-mount", "reload"]);
|
|
533
|
+
assert.deepEqual(f.lifecycle, ["done", "hidden", "disposed", "editor-mount", ...(shortcut ? [] : ["reload"])]);
|
|
451
534
|
assert.equal(f.closes(), 1);
|
|
452
535
|
assert.equal(f.tui.hasOverlay(), false);
|
|
453
|
-
|
|
536
|
+
if (shortcut) {
|
|
537
|
+
assert.equal(f.notices.length, 1);
|
|
538
|
+
assert.match(f.notices[0]!, /saved.*run \/reload.*manually/i);
|
|
539
|
+
} else assert.deepEqual(f.notices, [], "no stale-context notification even after reload rejection");
|
|
454
540
|
assert.match(readFileSync(join(agentDir, "settings.json"), "utf8"), /"enabled": true/);
|
|
455
541
|
} finally {
|
|
456
542
|
f.cleanup();
|
|
@@ -459,14 +545,27 @@ void test("registered TODO reload follows public done, SDK hide/dispose and defe
|
|
|
459
545
|
}
|
|
460
546
|
});
|
|
461
547
|
|
|
462
|
-
void test("
|
|
548
|
+
void test("Ctrl+Alt+O ignores a reload result after runtime shutdown", async () => {
|
|
549
|
+
const f = await focusFixture({ registered: true, shortcut: true });
|
|
550
|
+
try {
|
|
551
|
+
f.shutdown();
|
|
552
|
+
f.lifecycle.length = 0;
|
|
553
|
+
f.complete({ kind: "reload" });
|
|
554
|
+
await f.showing;
|
|
555
|
+
assert.deepEqual(f.lifecycle, ["done", "hidden", "disposed"]);
|
|
556
|
+
assert.deepEqual(f.notices, []);
|
|
557
|
+
assert.equal(f.tui.hasOverlay(), false);
|
|
558
|
+
} finally { f.cleanup(); }
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
for (const shortcut of [false, true]) void test(`registered ${shortcut ? "Ctrl+Alt+O" : "command"} Agents uses synthetic normal Pi and handles reload only after SDK disposal and hold release`, { timeout: 5000 }, async () => {
|
|
463
562
|
const previousHome = process.env.HOME; const previousDir = process.env.PI_CODING_AGENT_DIR;
|
|
464
563
|
const home = mkdtempSync(join(tmpdir(), "osdy-cc08-runtime-"));
|
|
465
564
|
const agentDir = join(home, ".pi", "agent"); mkdirSync(agentDir, { recursive: true });
|
|
466
565
|
const path = join(agentDir, "settings.json");
|
|
467
566
|
writeFileSync(path, JSON.stringify({ packages: ["npm:gentle-pi", "npm:pi-subagents-j0k3r"], unrelated: true }));
|
|
468
567
|
process.env.HOME = home; delete process.env.PI_CODING_AGENT_DIR;
|
|
469
|
-
const f = await focusFixture({ registered: true, todoDir: home });
|
|
568
|
+
const f = await focusFixture({ registered: true, shortcut, todoDir: home });
|
|
470
569
|
try {
|
|
471
570
|
f.lifecycle.length = 0;
|
|
472
571
|
for (let i = 0; i < 3; i++) f.send("\x1b[B");
|
|
@@ -481,8 +580,12 @@ void test("registered Agents uses synthetic normal Pi and reloads only after SDK
|
|
|
481
580
|
f.send("\r"); assert.equal(f.closes(), 0);
|
|
482
581
|
f.send("g"); f.text(); f.send("\x1b[B"); f.send("\r");
|
|
483
582
|
await f.showing;
|
|
484
|
-
assert.deepEqual(f.lifecycle, ["done", "hidden", "disposed", "editor-mount", "reload"]);
|
|
485
|
-
assert.equal(f.closes(), 1); assert.equal(f.tui.hasOverlay(), false);
|
|
583
|
+
assert.deepEqual(f.lifecycle, ["done", "hidden", "disposed", "editor-mount", ...(shortcut ? [] : ["reload"])]);
|
|
584
|
+
assert.equal(f.closes(), 1); assert.equal(f.tui.hasOverlay(), false);
|
|
585
|
+
if (shortcut) {
|
|
586
|
+
assert.equal(f.notices.length, 1);
|
|
587
|
+
assert.match(f.notices[0]!, /saved.*run \/reload.*manually/i);
|
|
588
|
+
} else assert.deepEqual(f.notices, []);
|
|
486
589
|
assert.match(readFileSync(path, "utf8"), /-\.\/index.ts/);
|
|
487
590
|
assert.match(readFileSync(path, "utf8"), /"unrelated": true/);
|
|
488
591
|
} finally {
|
|
@@ -819,9 +922,9 @@ void test("registered runtime modal wiring defers editor changes and shutdown ca
|
|
|
819
922
|
} finally { f.cleanup(); }
|
|
820
923
|
});
|
|
821
924
|
|
|
822
|
-
void test(
|
|
925
|
+
for (const shortcut of [false, true]) void test(`registered ${shortcut ? "Ctrl+Alt+O" : "command"} session transition ignores the old modal finally and uses the new watcher`, async (t) => {
|
|
823
926
|
t.mock.timers.enable({ apis: ["setInterval"] });
|
|
824
|
-
const f = await focusFixture({ registered: true });
|
|
927
|
+
const f = await focusFixture({ registered: true, shortcut });
|
|
825
928
|
try {
|
|
826
929
|
chooseVisual(f, 3);
|
|
827
930
|
await f.transition();
|
|
@@ -845,10 +948,11 @@ void test("registered runtime session transition ignores the old modal finally a
|
|
|
845
948
|
} finally { f.cleanup(); }
|
|
846
949
|
});
|
|
847
950
|
|
|
848
|
-
void test("registered
|
|
951
|
+
void test("registered Ctrl+Alt+O and osdyConfig share the same panel, live values and legacy persistence/notifications", async () => {
|
|
849
952
|
const previousDir = process.env.PI_CODING_AGENT_DIR;
|
|
850
953
|
process.env.PI_CODING_AGENT_DIR = fileURLToPath(new URL("./.startup-test-missing", import.meta.url));
|
|
851
954
|
const commands = new Map<string, { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }>();
|
|
955
|
+
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
852
956
|
const writes: GlobalEditorSettings[] = [];
|
|
853
957
|
const notices: { message: string; level: string | undefined }[] = [];
|
|
854
958
|
const gitCalls: { command: string; args: string[] }[] = [];
|
|
@@ -856,6 +960,7 @@ void test("registered legacy commands and osdyConfig share live values and exist
|
|
|
856
960
|
let mounts = 0;
|
|
857
961
|
let panels = 0;
|
|
858
962
|
const pi = { registerCommand: (name: string, command: { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }) => commands.set(name, command),
|
|
963
|
+
registerShortcut: (key: string, shortcut: ShortcutRegistration) => shortcuts.set(key, shortcut),
|
|
859
964
|
on: () => {}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
|
|
860
965
|
exec: (command: string, args: string[]) => {
|
|
861
966
|
gitCalls.push({ command, args: [...args] });
|
|
@@ -922,16 +1027,24 @@ void test("registered legacy commands and osdyConfig share live values and exist
|
|
|
922
1027
|
assert.deepEqual(notices.at(-1), { message: "osdy-pi mascot: Osdy-Halloween", level: "info" });
|
|
923
1028
|
for (const args of ["working-tree status", "header status", "mascot status", "editor status", "header invalid", "mascot bts extra", "mascot osdy-halloween extra", "mascot invalid", "editor simple extra"]) await legacy.handler(args, ctx);
|
|
924
1029
|
assert.equal(writes.length, before, "status and invalid inputs never save");
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
assert.
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1030
|
+
assert.equal(shortcuts.has("f1"), false);
|
|
1031
|
+
assert.equal(shortcuts.has("f2"), false);
|
|
1032
|
+
assert.deepEqual([...shortcuts.keys()].sort(), ["ctrl+alt+o", "ctrl+alt+u"]);
|
|
1033
|
+
assert.equal("reload" in ctx, false);
|
|
1034
|
+
for (const open of [() => modal.handler("", ctx), () => shortcuts.get("ctrl+alt+o")!.handler(ctx)]) {
|
|
1035
|
+
await legacy.handler("header neon", ctx);
|
|
1036
|
+
await legacy.handler("working-tree off", ctx);
|
|
1037
|
+
const beforeModalGitCalls = gitCalls.length;
|
|
1038
|
+
const beforeModalNotices = notices.length;
|
|
1039
|
+
await open();
|
|
1040
|
+
assert.deepEqual(notices.slice(beforeModalNotices).filter((notice) => notice.level === "error"), [],
|
|
1041
|
+
"modal integration assertions must not be swallowed as Control Center error notifications");
|
|
1042
|
+
assert.deepEqual(gitCalls.slice(beforeModalGitCalls, beforeModalGitCalls + 2), [
|
|
1043
|
+
{ command: "git", args: ["--no-optional-locks", "branch", "--show-current"] },
|
|
1044
|
+
{ command: "git", args: ["--no-optional-locks", "status", "--short", "--untracked-files=normal"] },
|
|
1045
|
+
], "both entry points inspect Git through the same read-only owner");
|
|
1046
|
+
}
|
|
1047
|
+
assert.equal(panels, 2);
|
|
935
1048
|
failure = true;
|
|
936
1049
|
await legacy.handler("working-tree off", ctx);
|
|
937
1050
|
assert.match(notices.at(-1)?.message ?? "", /disabled but could not be saved/);
|
|
@@ -950,6 +1063,52 @@ void test("registered legacy commands and osdyConfig share live values and exist
|
|
|
950
1063
|
}
|
|
951
1064
|
});
|
|
952
1065
|
|
|
1066
|
+
void test("registered shortcuts preserve command no-UI and Control Center mode/error guards", async () => {
|
|
1067
|
+
const previous = process.env.PI_CODING_AGENT_DIR;
|
|
1068
|
+
process.env.PI_CODING_AGENT_DIR = fileURLToPath(new URL("./.startup-test-missing", import.meta.url));
|
|
1069
|
+
const commands = new Map<string, { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }>();
|
|
1070
|
+
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
1071
|
+
const notices: { message: string; level: string | undefined }[] = [];
|
|
1072
|
+
let customCalls = 0;
|
|
1073
|
+
const pi = { registerCommand: (name: string, command: { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }) => commands.set(name, command),
|
|
1074
|
+
registerShortcut: (key: string, shortcut: ShortcutRegistration) => shortcuts.set(key, shortcut),
|
|
1075
|
+
on: () => {}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
|
|
1076
|
+
events: { on: () => () => {} }, exec: () => { throw new Error("external execution forbidden"); },
|
|
1077
|
+
} as unknown as ExtensionAPI;
|
|
1078
|
+
const ctx = { cwd: "/synthetic", hasUI: false, mode: "tui", ui: {
|
|
1079
|
+
notify: (message: string, level?: string) => notices.push({ message, level }),
|
|
1080
|
+
custom: () => { customCalls++; throw new Error("overlay failed"); },
|
|
1081
|
+
setEditorComponent: () => {}, setHeader: () => {}, setFooter: () => {}, setWidget: () => {}, setWorkingVisible: () => {},
|
|
1082
|
+
} } as unknown as ExtensionContext;
|
|
1083
|
+
const commandCtx = { ...ctx, reload: () => { throw new Error("reload forbidden"); } } as unknown as ExtensionCommandContext;
|
|
1084
|
+
try {
|
|
1085
|
+
await registerOsdyPi(pi);
|
|
1086
|
+
assert.equal(shortcuts.has("f1"), false);
|
|
1087
|
+
assert.equal(shortcuts.has("f2"), false);
|
|
1088
|
+
assert.deepEqual([...shortcuts.keys()].sort(), ["ctrl+alt+o", "ctrl+alt+u"]);
|
|
1089
|
+
for (const [key, command] of [["ctrl+alt+o", "osdyConfig"], ["ctrl+alt+u", "usage"]] as const) {
|
|
1090
|
+
notices.length = 0;
|
|
1091
|
+
await commands.get(command)!.handler("", commandCtx);
|
|
1092
|
+
const commandNotice = notices.at(-1);
|
|
1093
|
+
await shortcuts.get(key)!.handler(ctx);
|
|
1094
|
+
assert.deepEqual(notices.at(-1), commandNotice);
|
|
1095
|
+
assert.equal(commandNotice?.level, "warning");
|
|
1096
|
+
assert.match(commandNotice?.message ?? "", /interactive.*UI/);
|
|
1097
|
+
}
|
|
1098
|
+
assert.equal(customCalls, 0);
|
|
1099
|
+
for (const mode of ["rpc", "json", "print"] as const) {
|
|
1100
|
+
await shortcuts.get("ctrl+alt+o")!.handler({ ...ctx, mode, hasUI: true });
|
|
1101
|
+
assert.match(notices.at(-1)?.message ?? "", /interactive terminal UI/);
|
|
1102
|
+
}
|
|
1103
|
+
assert.equal(customCalls, 0);
|
|
1104
|
+
await shortcuts.get("ctrl+alt+o")!.handler({ ...ctx, hasUI: true });
|
|
1105
|
+
assert.equal(customCalls, 1);
|
|
1106
|
+
assert.deepEqual(notices.at(-1), { message: "Control Center unavailable: overlay failed", level: "error" });
|
|
1107
|
+
} finally {
|
|
1108
|
+
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previous;
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
|
|
953
1112
|
void test("actual factory registers no TODO surfaces by default, and all three only with opt-in", async () => {
|
|
954
1113
|
const root = mkdtempSync(join(tmpdir(), "osdy-runtime-todo-"));
|
|
955
1114
|
const agentDir = join(root, "agent"); mkdirSync(agentDir);
|
|
@@ -986,7 +1145,10 @@ void test("actual factory registers no TODO surfaces by default, and all three o
|
|
|
986
1145
|
assert.ok(commands.includes("osdyConfig"));
|
|
987
1146
|
assert.equal(commands.includes("osdy"), false);
|
|
988
1147
|
assert.equal(commands.includes("todos"), enabled); assert.equal(tools.includes("todo"), enabled);
|
|
989
|
-
assert.
|
|
1148
|
+
assert.ok(shortcuts.includes("ctrl+alt+o")); assert.ok(shortcuts.includes("ctrl+alt+u"));
|
|
1149
|
+
assert.equal(shortcuts.includes("f1"), false); assert.equal(shortcuts.includes("f2"), false);
|
|
1150
|
+
assert.equal(shortcuts.includes("ctrl+shift+t"), enabled);
|
|
1151
|
+
assert.equal(shortcuts.length, enabled ? 3 : 2);
|
|
990
1152
|
assert.equal(execCalls, probes);
|
|
991
1153
|
}
|
|
992
1154
|
} finally {
|
|
@@ -1020,7 +1182,10 @@ void test("official SDK factory uses SDK cwd, not the safe process cwd, before T
|
|
|
1020
1182
|
assert.equal(extension.commands.has("osdy"), false);
|
|
1021
1183
|
assert.equal(extension.tools.has("todo"), active, cwd);
|
|
1022
1184
|
assert.equal(extension.commands.has("todos"), active, cwd);
|
|
1023
|
-
assert.
|
|
1185
|
+
assert.ok(extension.shortcuts.has("ctrl+alt+o")); assert.ok(extension.shortcuts.has("ctrl+alt+u"));
|
|
1186
|
+
assert.equal(extension.shortcuts.has("f1"), false); assert.equal(extension.shortcuts.has("f2"), false);
|
|
1187
|
+
assert.equal(extension.shortcuts.has("ctrl+shift+t"), active, cwd);
|
|
1188
|
+
assert.equal(extension.shortcuts.size, active ? 3 : 2, cwd);
|
|
1024
1189
|
} finally { runtime.invalidate(); }
|
|
1025
1190
|
}
|
|
1026
1191
|
} finally {
|
|
@@ -1048,7 +1213,8 @@ void test("unavailable, failed and invalid SDK cwd probes fail closed", async ()
|
|
|
1048
1213
|
} as unknown as ExtensionAPI;
|
|
1049
1214
|
await registerOsdyPi(pi);
|
|
1050
1215
|
assert.ok(commands.includes("osdy-pi")); assert.equal(commands.includes("todos"), false);
|
|
1051
|
-
assert.equal(tools.includes("todo"), false); assert.
|
|
1216
|
+
assert.equal(tools.includes("todo"), false); assert.deepEqual(shortcuts.sort(), ["ctrl+alt+o", "ctrl+alt+u"]);
|
|
1217
|
+
assert.equal(shortcuts.includes("f1"), false); assert.equal(shortcuts.includes("f2"), false);
|
|
1052
1218
|
}
|
|
1053
1219
|
} finally {
|
|
1054
1220
|
if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previous;
|
|
@@ -1168,7 +1334,7 @@ void test("real session_start restores labels before editor mounting and rejects
|
|
|
1168
1334
|
registered.push(handler);
|
|
1169
1335
|
handlers.set(name, registered);
|
|
1170
1336
|
},
|
|
1171
|
-
registerCommand: () => {}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
|
|
1337
|
+
registerCommand: () => {}, registerShortcut: () => {}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
|
|
1172
1338
|
events: { on: () => () => {} },
|
|
1173
1339
|
exec: () => Promise.resolve({ code: 1, stdout: "", stderr: "unavailable" }),
|
|
1174
1340
|
} as unknown as ExtensionAPI;
|
|
@@ -1507,6 +1673,8 @@ class IdleQuotaClock implements CodexUsageRefreshClock {
|
|
|
1507
1673
|
|
|
1508
1674
|
async function idleQuotaFixture() {
|
|
1509
1675
|
const clock = new IdleQuotaClock();
|
|
1676
|
+
const shortcuts = new Map<string, ShortcutRegistration>();
|
|
1677
|
+
const authSessions: number[] = [];
|
|
1510
1678
|
const commands = new Map<string, { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }>();
|
|
1511
1679
|
const handlers = new Map<string, Array<(event: unknown, ctx: ExtensionContext) => Promise<void> | void>>();
|
|
1512
1680
|
const requests: Array<{ signal: AbortSignal; resolve(snapshot: CodexUsageSnapshot): void; reject(error: Error): void }> = [];
|
|
@@ -1515,16 +1683,19 @@ async function idleQuotaFixture() {
|
|
|
1515
1683
|
const snapshot = (fetchedAt: number): CodexUsageSnapshot => ({ fetchedAt, planType: "plus", ordinaryUsageAllowed: true,
|
|
1516
1684
|
buckets: [], credits: undefined });
|
|
1517
1685
|
const pi = { registerCommand: (name: string, command: { handler(args: string, ctx: ExtensionCommandContext): Promise<void> }) => commands.set(name, command),
|
|
1686
|
+
registerShortcut: (key: string, shortcut: ShortcutRegistration) => shortcuts.set(key, shortcut),
|
|
1518
1687
|
on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<void> | void) => {
|
|
1519
1688
|
const list = handlers.get(name) ?? []; list.push(handler); handlers.set(name, list);
|
|
1520
1689
|
}, registerFlag: () => {}, registerMessageRenderer: () => {}, registerEntryRenderer: () => {},
|
|
1521
1690
|
events: { on: () => () => {} }, exec: () => { throw new Error("external execution forbidden"); },
|
|
1522
1691
|
} as unknown as ExtensionAPI;
|
|
1523
1692
|
const accessToken = `header.${Buffer.from(JSON.stringify({ "https://api.openai.com/auth": { chatgpt_account_id: "fake-account" } })).toString("base64url")}.signature`;
|
|
1524
|
-
const makeContext = () =>
|
|
1693
|
+
const makeContext = () => {
|
|
1694
|
+
const generation = session;
|
|
1695
|
+
return ({ cwd: "/synthetic-idle-quota", get hasUI() { return hasUI; }, get mode() { return mode; },
|
|
1525
1696
|
get model() { return { provider, id: "fake-codex", name: `session-${session}` }; }, isIdle: () => idle,
|
|
1526
1697
|
getThinkingLevel: () => "off", modelRegistry: { getProviderAuth: () => {
|
|
1527
|
-
authCalls++; return Promise.resolve({ auth: { apiKey: accessToken } });
|
|
1698
|
+
authCalls++; authSessions.push(generation); return Promise.resolve({ auth: { apiKey: accessToken } });
|
|
1528
1699
|
} }, ui: { getEditorComponent: () => undefined, setEditorComponent: () => {}, setHeader: () => {}, setFooter: () => {},
|
|
1529
1700
|
setWidget: () => {}, setWorkingVisible: () => {}, notify: () => {},
|
|
1530
1701
|
custom: (factory: (tui: unknown, theme: unknown, keys: unknown, done: () => void) => Component) => {
|
|
@@ -1532,7 +1703,8 @@ async function idleQuotaFixture() {
|
|
|
1532
1703
|
return Promise.resolve();
|
|
1533
1704
|
},
|
|
1534
1705
|
},
|
|
1535
|
-
|
|
1706
|
+
}) as unknown as ExtensionContext;
|
|
1707
|
+
};
|
|
1536
1708
|
let ctx = makeContext();
|
|
1537
1709
|
const previousDir = process.env.PI_CODING_AGENT_DIR;
|
|
1538
1710
|
// A nonexistent synthetic directory prevents factory inspection of personal settings.
|
|
@@ -1553,10 +1725,17 @@ async function idleQuotaFixture() {
|
|
|
1553
1725
|
assert.ok(handler, `runtime ${name} handler must be registered`);
|
|
1554
1726
|
await handler({}, ctx); await clock.advance(0);
|
|
1555
1727
|
};
|
|
1556
|
-
return { clock, requests, snapshot, authCalls: () => authCalls,
|
|
1728
|
+
return { clock, requests, snapshot, authSessions, authCalls: () => authCalls, context: () => ctx,
|
|
1557
1729
|
start: () => lifecycle("session_start"), stop: () => lifecycle("session_shutdown"), settle: () => lifecycle("agent_settled"),
|
|
1558
1730
|
replace: async () => { session++; ctx = makeContext(); await lifecycle("session_start"); },
|
|
1559
1731
|
manual: async () => { await commands.get("usage")!.handler("", ctx as ExtensionCommandContext); await clock.advance(0); },
|
|
1732
|
+
shortcut: async (context: ExtensionContext = ctx) => {
|
|
1733
|
+
assert.ok(shortcuts.has("ctrl+alt+u"), "Ctrl+Alt+U must open subscription usage");
|
|
1734
|
+
assert.equal(shortcuts.has("f1"), false);
|
|
1735
|
+
assert.equal(shortcuts.has("f2"), false);
|
|
1736
|
+
assert.equal("reload" in context, false);
|
|
1737
|
+
await shortcuts.get("ctrl+alt+u")!.handler(context); await clock.advance(0);
|
|
1738
|
+
},
|
|
1560
1739
|
visual: async (action: "on" | "off") => { await commands.get("osdy-pi")!.handler(action, ctx as ExtensionCommandContext); },
|
|
1561
1740
|
panel: () => panel!.render(100).join("\n"),
|
|
1562
1741
|
set: (values: { enabled?: boolean; hasUI?: boolean; idle?: boolean; provider?: string; mode?: string }) => {
|
|
@@ -1588,6 +1767,29 @@ void test("registered idle quota runtime respects timing, in-flight manual reque
|
|
|
1588
1767
|
} finally { await f.stop(); }
|
|
1589
1768
|
});
|
|
1590
1769
|
|
|
1770
|
+
void test("Ctrl+Alt+U opens the usage command panel and refreshes the latest session from a base context", async () => {
|
|
1771
|
+
const f = await idleQuotaFixture();
|
|
1772
|
+
try {
|
|
1773
|
+
await f.start(); await f.complete(0);
|
|
1774
|
+
await f.manual();
|
|
1775
|
+
const commandPanel = f.panel();
|
|
1776
|
+
await f.shortcut();
|
|
1777
|
+
assert.equal(f.panel(), commandPanel, "both entry points render the same loading dashboard and metadata");
|
|
1778
|
+
assert.equal(f.requests.length, 3);
|
|
1779
|
+
assert.equal(f.requests[1]?.signal.aborted, true, "Ctrl+Alt+U supersedes the command refresh");
|
|
1780
|
+
await f.complete(1); await f.complete(2);
|
|
1781
|
+
assert.match(f.panel(), /Codex subscription usage/);
|
|
1782
|
+
const oldContext = f.context();
|
|
1783
|
+
await f.replace(); await f.complete(3);
|
|
1784
|
+
await f.shortcut(oldContext);
|
|
1785
|
+
assert.equal(f.authSessions.at(-1), 1, "even an older shortcut context refreshes current session auth");
|
|
1786
|
+
await f.stop();
|
|
1787
|
+
assert.equal(f.requests[4]?.signal.aborted, true);
|
|
1788
|
+
await f.complete(4);
|
|
1789
|
+
assert.equal(f.clock.jobs.size, 0, "shutdown ignores the late shortcut query result");
|
|
1790
|
+
} finally { await f.stop(); }
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1591
1793
|
void test("registered idle quota runtime skips disabled UI, non-Codex and busy sessions", async () => {
|
|
1592
1794
|
const f = await idleQuotaFixture();
|
|
1593
1795
|
try {
|
|
@@ -779,24 +779,29 @@ function registerUsageCommand(
|
|
|
779
779
|
getSessionContext,
|
|
780
780
|
startRefresh,
|
|
781
781
|
);
|
|
782
|
+
const openUsagePanel = async (ctx: ExtensionContext): Promise<void> => {
|
|
783
|
+
if (!ctx.hasUI) {
|
|
784
|
+
ctx.ui.notify("Codex usage requires the interactive UI", "warning");
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
await showCodexUsagePanel(
|
|
788
|
+
ctx,
|
|
789
|
+
() => state.codexUsage,
|
|
790
|
+
refreshActiveSessionUsage,
|
|
791
|
+
{
|
|
792
|
+
profile: resolveActiveProfileLabel(),
|
|
793
|
+
provider: ctx.model?.provider ?? "unknown",
|
|
794
|
+
model: modelLabel(ctx),
|
|
795
|
+
},
|
|
796
|
+
);
|
|
797
|
+
};
|
|
782
798
|
pi.registerCommand("usage", {
|
|
783
799
|
description: "Show current Codex subscription usage.",
|
|
784
|
-
handler: async (_args, ctx) =>
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
await showCodexUsagePanel(
|
|
790
|
-
ctx,
|
|
791
|
-
() => state.codexUsage,
|
|
792
|
-
refreshActiveSessionUsage,
|
|
793
|
-
{
|
|
794
|
-
profile: resolveActiveProfileLabel(),
|
|
795
|
-
provider: ctx.model?.provider ?? "unknown",
|
|
796
|
-
model: modelLabel(ctx),
|
|
797
|
-
},
|
|
798
|
-
);
|
|
799
|
-
},
|
|
800
|
+
handler: async (_args, ctx) => openUsagePanel(ctx),
|
|
801
|
+
});
|
|
802
|
+
pi.registerShortcut("ctrl+alt+u", {
|
|
803
|
+
description: "Show current Codex subscription usage.",
|
|
804
|
+
handler: openUsagePanel,
|
|
800
805
|
});
|
|
801
806
|
}
|
|
802
807
|
|
|
@@ -895,43 +900,54 @@ function registerCommand(
|
|
|
895
900
|
captureCurrentRuntime: () => () => boolean,
|
|
896
901
|
accountFactory: typeof bindControlCenterAccount,
|
|
897
902
|
): void {
|
|
903
|
+
const openControlCenter = async (
|
|
904
|
+
ctx: ExtensionContext,
|
|
905
|
+
onReloadRequired: () => Promise<void> | void,
|
|
906
|
+
): Promise<void> => {
|
|
907
|
+
const isCurrent = captureCurrentRuntime();
|
|
908
|
+
const completion: { result: ControlCenterResult } = { result: { kind: "closed" } };
|
|
909
|
+
await withEditorMountHold(pi, ctx, state, workingTreeState, async () => {
|
|
910
|
+
completion.result = await showControlCenter(ctx, {
|
|
911
|
+
snapshot: () => state,
|
|
912
|
+
apply: (action) => isCurrent()
|
|
913
|
+
? applyVisualPreference(action, pi, ctx, state, workingState, workingTreeState, editorSettingsStore)
|
|
914
|
+
: Promise.resolve(false),
|
|
915
|
+
}, {
|
|
916
|
+
git: createControlCenterGit({
|
|
917
|
+
snapshot: () => ({ enabled: state.workingTreeEnabled, placement: state.workingTreePlacement }),
|
|
918
|
+
exec: async (args) => {
|
|
919
|
+
const result = await pi.exec("git", args, { cwd: ctx.cwd, timeout: 5000 });
|
|
920
|
+
if (result.code !== 0 || result.killed) throw new Error("Git inspection failed");
|
|
921
|
+
return result.stdout;
|
|
922
|
+
},
|
|
923
|
+
applyEnabled: (value) => applyWorkingTreeEnabled(value, pi, ctx, state, workingState, workingTreeState, editorSettingsStore),
|
|
924
|
+
}),
|
|
925
|
+
sounds: bindControlCenterSounds(pi, ctx, settingsStore, createAudioPlaybackAdapter()),
|
|
926
|
+
account: accountFactory(ctx, refreshUsage, () => state.tui?.requestRender(), undefined, { isCurrent }),
|
|
927
|
+
usage: createControlCenterUsage({ quota: () => state.codexUsage, history: readUsageHistory,
|
|
928
|
+
refresh: refreshUsage, active: readActiveProfileName }),
|
|
929
|
+
todo: createControlCenterTodo({ agentDir: todoAgentDir(), cwd: ctx.cwd,
|
|
930
|
+
loaded: todoActive, isIdle: () => ctx.isIdle(), isCurrent }),
|
|
931
|
+
agents: createControlCenterAgents({ agentDir: todoAgentDir(), cwd: ctx.cwd,
|
|
932
|
+
env: { ...process.env }, isIdle: () => ctx.isIdle(), isCurrent }),
|
|
933
|
+
});
|
|
934
|
+
}, isCurrent);
|
|
935
|
+
// Disposal and hold release precede either effect; check freshness without another await.
|
|
936
|
+
if (completion.result.kind === "reload" && isCurrent()) await onReloadRequired();
|
|
937
|
+
};
|
|
898
938
|
pi.registerCommand("osdyConfig", {
|
|
899
939
|
description: "Open Osdy Control Center (preferences, Git, Sounds, Account, Usage, TODO and Agents).",
|
|
900
|
-
handler: async (_args, ctx) => {
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
snapshot: () => ({ enabled: state.workingTreeEnabled, placement: state.workingTreePlacement }),
|
|
912
|
-
exec: async (args) => {
|
|
913
|
-
const result = await pi.exec("git", args, { cwd: ctx.cwd, timeout: 5000 });
|
|
914
|
-
if (result.code !== 0 || result.killed) throw new Error("Git inspection failed");
|
|
915
|
-
return result.stdout;
|
|
916
|
-
},
|
|
917
|
-
applyEnabled: (value) => applyWorkingTreeEnabled(value, pi, ctx, state, workingState, workingTreeState, editorSettingsStore),
|
|
918
|
-
}),
|
|
919
|
-
sounds: bindControlCenterSounds(pi, ctx, settingsStore, createAudioPlaybackAdapter()),
|
|
920
|
-
account: accountFactory(ctx, refreshUsage, () => state.tui?.requestRender(), undefined, { isCurrent }),
|
|
921
|
-
usage: createControlCenterUsage({ quota: () => state.codexUsage, history: readUsageHistory,
|
|
922
|
-
refresh: refreshUsage, active: readActiveProfileName }),
|
|
923
|
-
todo: createControlCenterTodo({ agentDir: todoAgentDir(), cwd: ctx.cwd,
|
|
924
|
-
loaded: todoActive, isIdle: () => ctx.isIdle(), isCurrent }),
|
|
925
|
-
agents: createControlCenterAgents({ agentDir: todoAgentDir(), cwd: ctx.cwd,
|
|
926
|
-
env: { ...process.env }, isIdle: () => ctx.isIdle(), isCurrent }),
|
|
927
|
-
});
|
|
928
|
-
}, isCurrent);
|
|
929
|
-
if (completion.result.kind === "reload" && isCurrent()) {
|
|
930
|
-
// Disposal and CC06 hold release precede reload; old context is unusable afterward.
|
|
931
|
-
try { await ctx.reload(); } catch { return; }
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
},
|
|
940
|
+
handler: async (_args, ctx) => openControlCenter(ctx, async () => {
|
|
941
|
+
// Keep reload bound to the command context; never use that context afterward.
|
|
942
|
+
try { await ctx.reload(); } catch { return; }
|
|
943
|
+
}),
|
|
944
|
+
});
|
|
945
|
+
pi.registerShortcut("ctrl+alt+o", {
|
|
946
|
+
description: "Open Osdy Control Center.",
|
|
947
|
+
handler: async (ctx) => openControlCenter(ctx, () => {
|
|
948
|
+
// Shortcuts receive only the base context, without command-only reload.
|
|
949
|
+
ctx.ui.notify("Settings saved. Run /reload manually to apply the changes.", "info");
|
|
950
|
+
}),
|
|
935
951
|
});
|
|
936
952
|
pi.registerCommand("osdy-pi", {
|
|
937
953
|
description:
|
|
@@ -260,7 +260,7 @@ void test("Current and Bts retain their original glyphs, tone maps, and palettes
|
|
|
260
260
|
}
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
void test("Osdy-Halloween preserves Current's
|
|
263
|
+
void test("Osdy-Halloween preserves Current's muzzle and tail beneath a fang-free cape", () => {
|
|
264
264
|
const { art, tonePalette } = mascotForChoice("osdy-halloween");
|
|
265
265
|
const current = MASCOTS.current.art;
|
|
266
266
|
assert.deepEqual(art.mascot.slice(0, 17), current.mascot.slice(0, 17), "same ears, mask, eyes and snout");
|
|
@@ -268,13 +268,18 @@ void test("Osdy-Halloween preserves Current's face and tail beneath a bounded va
|
|
|
268
268
|
assert.equal(art.mascot[row]?.slice(44), current.mascot[row]?.slice(44), "original striped tail");
|
|
269
269
|
}
|
|
270
270
|
assert.notDeepEqual(art.mascot.slice(17), current.mascot.slice(17));
|
|
271
|
-
|
|
272
|
-
assert.
|
|
273
|
-
|
|
274
|
-
|
|
271
|
+
assert.doesNotMatch(art.mascot.join("\n"), /▼/, "no fang tips");
|
|
272
|
+
assert.doesNotMatch(art.mascot.slice(17, 20).join("\n"), /█/, "no fang stems on the muzzle");
|
|
273
|
+
for (const row of [18, 19]) {
|
|
274
|
+
for (const column of [22, 33]) {
|
|
275
|
+
assert.equal(art.mascot[row]?.[column], current.mascot[row]?.[column], "original muzzle at removed fang positions");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
275
278
|
assert.equal(art.mascot.length, current.mascot.length);
|
|
276
279
|
assert.deepEqual(art.mascot.map((row) => row.length), current.mascot.map((row) => row.length));
|
|
280
|
+
assert.equal(art.mascot[17]?.[8], "▓", "raised collar stays in place");
|
|
277
281
|
assert.equal(art.toneMap[17]?.[8], "d", "raised collar has a dark outer edge");
|
|
282
|
+
assert.equal(art.mascot[20]?.[12], "▒", "collar lining stays in place");
|
|
278
283
|
assert.equal(art.toneMap[20]?.[12], "b", "collar has crimson lining");
|
|
279
284
|
assert.equal(tonePalette.b, "#9F2342");
|
|
280
285
|
assert.equal(tonePalette.d, MASCOTS.current.tonePalette.d);
|
|
@@ -282,7 +287,11 @@ void test("Osdy-Halloween preserves Current's face and tail beneath a bounded va
|
|
|
282
287
|
const scaled = scaleMascot(art, width, rows);
|
|
283
288
|
assert.ok(scaled.mascot.length <= rows);
|
|
284
289
|
assert.equal(scaled.mascot.length, scaled.toneMap.length);
|
|
285
|
-
|
|
290
|
+
assert.doesNotMatch(scaled.mascot.join("\n"), /▼/, "responsive art has no fang tips");
|
|
291
|
+
if (width >= 32) {
|
|
292
|
+
assert.ok(scaled.mascot.some((line, row) => [...line].some((glyph, column) => glyph === "▓" && scaled.toneMap[row]?.[column] === "d")), "dark cape survives responsive sampling");
|
|
293
|
+
assert.ok(scaled.mascot.some((line, row) => [...line].some((glyph, column) => glyph === "▒" && scaled.toneMap[row]?.[column] === "b")), "crimson lining survives responsive sampling");
|
|
294
|
+
}
|
|
286
295
|
for (let row = 0; row < scaled.mascot.length; row++) {
|
|
287
296
|
const glyphs = Array.from(scaled.mascot[row] ?? "");
|
|
288
297
|
const tones = Array.from(scaled.toneMap[row] ?? "");
|
|
@@ -290,7 +299,7 @@ void test("Osdy-Halloween preserves Current's face and tail beneath a bounded va
|
|
|
290
299
|
assert.equal(glyphs.length, tones.length);
|
|
291
300
|
for (let column = 0; column < glyphs.length; column++) {
|
|
292
301
|
if (glyphs[column] !== " ") assert.ok(Object.keys(tonePalette).includes(tones[column] ?? ""));
|
|
293
|
-
|
|
302
|
+
|
|
294
303
|
}
|
|
295
304
|
}
|
|
296
305
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "osdy-pi",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.11",
|
|
4
4
|
"description": "Osdy Pi package: themes, header, and custom editor for Pi.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"typescript": "^5.9.3",
|
|
84
84
|
"typescript-eslint": "^8.46.3"
|
|
85
85
|
},
|
|
86
|
-
"releaseSource": "
|
|
86
|
+
"releaseSource": "108d5648e4958aef1ae9390d14fcb8332867d0b7",
|
|
87
87
|
"repository": {
|
|
88
88
|
"type": "git",
|
|
89
89
|
"url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"
|