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 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` and compact bars emphasize remaining Codex quota at warning (40% or less) and error (15% or less) thresholds. |
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
- 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.
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 reloading once; restart Pi if reload fails. Rejections stay in the panel. No installs or task-history changes occur; legacy `/osdy-pi todo on|off|status` remains available.
64
- - Agents shows concise **Joker / Gentle** choices without activating anything. With detail focus, visible lowercase-only **j Joker / g Gentle** letters open the existing confirmation directly; only available, unambiguous rows get shortcuts. **↑/↓ + Enter** remains the fallback. Profile and Agent selections survive category navigation; unavailable choices fall back to the current row. Letters never bypass category focus, confirmation, path input, loading, querying, or saving. Selection is idle-only and Cancel-first confirmed, naming the real target, possible Joker installation, owned agent filters, and preserved unrelated resources. Normal personal Pi only: `pi:dev` and isolated/override sessions are blocked, never redirected to personal settings. Success closes/disposes the panel and releases its editor hold before reloading once; restart Pi if reload fails. Legacy `/osdy-pi agents setup|on|off|status` is unchanged.
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, 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.
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
- Run `/usage` to open the 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.
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: () => false,
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
- ExtensionCommandContext,
17
- "isIdle" | "waitForIdle"
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, █/▼ = ivory fangs. Keep the face and tail.
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
- const ctx = { cwd: options.todoDir ?? "/synthetic", isIdle: () => true,
250
- reload: () => { lifecycle.push("reload"); return options.reloadFails ? Promise.reject(new Error("reload failed")) : Promise.resolve(); },
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 = () => finish(undefined);
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 = () => commands.get("osdyConfig")!.handler("", runtimeCtx as ExtensionCommandContext);
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("registered TODO reload follows public done, SDK hide/dispose and deferred editor mount release", async () => {
429
- for (const reloadFails of [false, true]) {
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
- assert.deepEqual(f.notices, [], "no stale-context notification even after reload rejection");
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("registered Agents uses synthetic normal Pi and reloads only after SDK disposal and hold release", { timeout: 5000 }, async () => {
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); assert.deepEqual(f.notices, []);
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("registered runtime session transition ignores the old modal finally and uses the new watcher", async (t) => {
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 legacy commands and osdyConfig share live values and existing persistence/notifications", async () => {
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
- const beforeModalGitCalls = gitCalls.length;
926
- const beforeModalNotices = notices.length;
927
- await modal.handler("", ctx);
928
- assert.deepEqual(notices.slice(beforeModalNotices).filter((notice) => notice.level === "error"), [],
929
- "modal integration assertions must not be swallowed as Control Center error notifications");
930
- assert.equal(panels, 1);
931
- assert.deepEqual(gitCalls.slice(beforeModalGitCalls, beforeModalGitCalls + 2), [
932
- { command: "git", args: ["--no-optional-locks", "branch", "--show-current"] },
933
- { command: "git", args: ["--no-optional-locks", "status", "--short", "--untracked-files=normal"] },
934
- ], "modal inspection uses exact read-only Git commands, including the global option");
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.equal(shortcuts.length > 0, enabled);
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.equal(extension.shortcuts.size > 0, active, cwd);
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.equal(shortcuts.length, 0);
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 = () => ({ cwd: "/synthetic-idle-quota", get hasUI() { return hasUI; }, get mode() { return mode; },
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
- }) as unknown as ExtensionContext;
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
- if (!ctx.hasUI) {
786
- ctx.ui.notify("Codex usage requires the interactive UI", "warning");
787
- return;
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
- const isCurrent = captureCurrentRuntime();
902
- const completion: { result: ControlCenterResult } = { result: { kind: "closed" } };
903
- await withEditorMountHold(pi, ctx, state, workingTreeState, async () => {
904
- completion.result = await showControlCenter(ctx, {
905
- snapshot: () => state,
906
- apply: (action) => isCurrent()
907
- ? applyVisualPreference(action, pi, ctx, state, workingState, workingTreeState, editorSettingsStore)
908
- : Promise.resolve(false),
909
- }, {
910
- git: createControlCenterGit({
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 face and tail beneath a bounded vampire costume", () => {
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
- const fangs = art.mascot.slice(17, 20).join("\n");
272
- assert.match(fangs, /▼[^▼\n]+▼/, "two ivory fang tips separated by the original muzzle");
273
- assert.equal([...fangs].filter((glyph) => glyph === "▼").length, 2, "one-column fang tips");
274
- assert.equal([...fangs].filter((glyph) => glyph === "█").length, 2, "one-column stems keep fangs two rows tall");
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
- if (width >= 32) assert.match(scaled.mascot.join("\n"), /▼+[^▼\n]+▼+/, "both fangs survive responsive sampling");
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
- if (glyphs[column] === "▼") assert.equal(tones[column], "h", "fangs retain ivory rather than glow/lining tones");
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.10",
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": "96f670c11788dfb1a336c664a266b8581546ff59",
86
+ "releaseSource": "108d5648e4958aef1ae9390d14fcb8332867d0b7",
87
87
  "repository": {
88
88
  "type": "git",
89
89
  "url": "git+https://github.com/OsdyOrtiz/Osdy-Pi.git"